diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 5f193fca6..514445f1c 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -10,6 +10,15 @@ body: placeholder: 例如:v3.3.0、CentOS x64 Python 3.10.3、Docker validations: required: true + - type: dropdown + attributes: + label: 部署版本 + description: 请选择您使用的 LangBot 部署版本。 + options: + - 社区版 + - 云服务 + validations: + required: true - type: textarea attributes: label: 异常情况 diff --git a/.github/ISSUE_TEMPLATE/bug-report_en.yml b/.github/ISSUE_TEMPLATE/bug-report_en.yml index 1cfba7d23..d2111e097 100644 --- a/.github/ISSUE_TEMPLATE/bug-report_en.yml +++ b/.github/ISSUE_TEMPLATE/bug-report_en.yml @@ -10,6 +10,15 @@ body: placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker" validations: required: true + - type: dropdown + attributes: + label: Deployment version + description: Please select the LangBot deployment version you are using. + options: + - Community Edition + - Cloud Service + validations: + required: true - type: textarea attributes: label: Exception diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 42899f8b9..add21184e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -21,6 +21,7 @@ *请在方括号间写`x`以打勾 / Please tick the box with `x`* - [ ] 阅读仓库[贡献指引](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)了吗? / Have you read the [contribution guide](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)? +- [ ] 我已签署或将在机器人提示后签署 [CLA](https://github.com/langbot-app/LangBot/blob/master/CLA.md)。 / I have signed, or will sign when prompted by the bot, the [CLA](https://github.com/langbot-app/LangBot/blob/master/CLA.md). - [ ] 与项目所有者沟通过了吗? / Have you communicated with the project maintainer? - [ ] 我确定已自行测试所作的更改,确保功能符合预期。 / I have tested the changes and ensured they work as expected. diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 000000000..996e5a686 --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,41 @@ +name: "CLA Assistant" +on: + issue_comment: + types: [created] + pull_request_target: + types: [opened, closed, synchronize, reopened] + +permissions: + actions: write # re-run the failed CLA check after signing + contents: read # signatures are stored in the remote langbot-app/cla repo + pull-requests: write # post guidance comments, lock PR after merge + statuses: write # set the commit status + +jobs: + CLAAssistant: + runs-on: ubuntu-latest + steps: + - name: "CLA Assistant" + if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' + # Upstream repo was archived in 2026-03; pin to the v2.6.1 commit SHA. + uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # repo-scope PAT with write access to langbot-app/cla + PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }} + with: + path-to-document: 'https://github.com/langbot-app/LangBot/blob/master/CLA.md' + remote-organization-name: 'langbot-app' + remote-repository-name: 'cla' + path-to-signatures: 'signatures/version1/cla.json' + branch: 'main' + allowlist: 'dependabot[bot],github-actions[bot],devin-ai-integration[bot],Copilot,renovate[bot],bot*' + custom-notsigned-prcomment: | + Thank you for your contribution! :heart: Before we can merge this pull request, we need you to sign the [LangBot Contributor License Agreement (CLA)](https://github.com/langbot-app/LangBot/blob/master/CLA.md). You keep full copyright of your code — the CLA grants us a license to use and distribute your contribution. Signing takes 10 seconds and covers all repositories in this organization, permanently. + + 感谢您的贡献!合并前请阅读并签署[贡献者许可协议(CLA)](https://github.com/langbot-app/LangBot/blob/master/CLA.md)。您保留代码的全部版权,签署仅需回复下方指定内容,一次签署对本组织全部仓库永久有效。 + custom-allsigned-prcomment: 'All contributors have signed the CLA. :white_check_mark: 所有贡献者均已签署 CLA。' + lock-pullrequest-aftermerge: true + # SECURITY: this workflow runs on pull_request_target (it holds secrets and has + # write access to the base repository). NEVER add an actions/checkout step that + # checks out the PR's code here. diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 000000000..0265e0441 --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,46 @@ +name: Frontend Tests + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'web/**' + - '.github/workflows/frontend-tests.yml' + push: + branches: + - master + - develop + paths: + - 'web/**' + - '.github/workflows/frontend-tests.yml' + +jobs: + playwright-smoke: + name: Playwright Smoke + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '25' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 8.9.2 + + - name: Install dependencies + working-directory: web + run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + working-directory: web + run: pnpm exec playwright install --with-deps chromium + + - name: Run Playwright smoke tests + working-directory: web + run: pnpm test:e2e diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e1d89c1ef..f2baae7c7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -29,7 +29,7 @@ jobs: run: uv sync --dev - name: Run ruff check - run: uv run ruff check src + run: uv run ruff check src/langbot/ tests/ --output-format=concise - name: Run ruff format run: uv run ruff format src --check diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 34f89f572..aaee59549 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -15,14 +15,10 @@ on: branches: - master - develop - paths: - - 'src/langbot/**' - - 'tests/**' - - '.github/workflows/run-tests.yml' - - 'pyproject.toml' - - 'uv.lock' - - 'run_tests.sh' - - 'scripts/test-*.sh' + - 'feat/**' + # No path filter on push: every push to the branches above runs the + # full unit-test suite. feat/** branches in particular must be tested + # on every push (they accumulate large changes before a PR exists). jobs: test: @@ -88,6 +84,67 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + e2e: + name: E2E Startup Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --dev + + - name: Run E2E startup tests + run: uv run pytest tests/e2e -q --tb=short + + - name: E2E Test Summary + if: always() + run: | + echo "## E2E Startup Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + + box-integration: + name: Box Integration Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --dev + + - name: Check Docker runtime + run: docker info + + - name: Run Box integration tests + run: uv run pytest tests/integration_tests -q --tb=short + + - name: Box Integration Test Summary + if: always() + run: | + echo "## Box Integration Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + coverage: name: Coverage Gate runs-on: ubuntu-latest @@ -133,4 +190,4 @@ jobs: echo "## Coverage Results" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Threshold: 18%" >> $GITHUB_STEP_SUMMARY - echo "Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY \ No newline at end of file + echo "Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index d0fe6acb6..97a64ba81 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ coverage.xml .coverage src/langbot/web/ testsdk/ +.qa/ # Build artifacts /dist diff --git a/AGENTS.md b/AGENTS.md index 10e59ca9e..a886d2e1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,81 +1,108 @@ # AGENTS.md -This file is for guiding code agents (like Claude Code, GitHub Copilot, OpenAI Codex, etc.) to work in LangBot project. +This file guides code agents working in the LangBot main repository. `CLAUDE.md` is a symlink to this file. -## Project Overview +Read `ARCHITECTURE.md` before non-trivial backend, frontend, runtime, plugin, Box, MCP, persistence, or cross-repo SDK changes. This file is the working checklist; `ARCHITECTURE.md` is the system map. -LangBot is a open-source LLM native instant messaging bot development platform, aiming to provide an out-of-the-box IM robot development experience, with Agent, RAG, MCP and other LLM application functions, supporting global instant messaging platforms, and providing rich API interfaces, supporting custom development. +## Quick Facts -LangBot has a comprehensive frontend, all operations can be performed through the frontend. The project splited into these major parts: +- Python backend: `>=3.11,<4.0`, dependencies managed by `uv`. +- Frontend: `web/` is Vite + React Router 7 + shadcn/ui + Tailwind, managed by `pnpm`. +- Backend framework: Quart served by Hypercorn on `api.port`, default `5300`. +- Frontend dev server: `web/` on `3000`, with `VITE_API_BASE_URL` pointing at the backend. +- Plugin/Box/runtime contracts live in sibling repo `langbot-plugin-sdk`, pinned as `langbot-plugin` in `pyproject.toml`. -- `./src/langbot`: The main python package of the project, below are the main modules in this package: - - `./pkg`: The core python package of the project backend. - - `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc. - - `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc. - - `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc. - - `./pkg/api`: The api module of the project, containing the http api controllers and services. - - `./pkg/plugin`: LangBot bridge for connecting with plugin system. - - `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc. - - `./templates`: Templates of config files, components, etc. - - `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**. - - `./docker`: docker-compose deployment files. - -## Backend Development - -We use `uv` to manage dependencies. +## Essential Commands ```bash -pip install uv uv sync --dev -``` - -Start the backend and run the project in development mode. - -```bash uv run main.py -``` +uv run pre-commit install -Then you can access the project at `http://127.0.0.1:5300`. - -## Frontend Development - -We use `pnpm` to manage dependencies. - -```bash cd web -cp .env.example .env pnpm install pnpm dev +pnpm build ``` -Then you can access the project at `http://127.0.0.1:3000`. +Useful focused tests: -## Plugin System Architecture +```bash +uv run pytest tests/unit_tests -q +uv run pytest tests/integration -q +uv run pytest tests/integration/persistence -q +uv run pytest tests/manual/mcp_smoke.py -LangBot is composed of various internal components such as Large Language Model tools, commands, messaging platform adapters, LLM requesters, and more. To meet extensibility and flexibility requirements, we have implemented a production-grade plugin system. +cd web +pnpm lint +pnpm test:e2e +``` -Each plugin runs in an independent process, managed uniformly by the Plugin Runtime. It has two operating modes: `stdio` and `websocket`. When LangBot is started directly by users (not running in a container), it uses `stdio` mode, which is common for personal users or lightweight environments. When LangBot runs in a container, it uses `websocket` mode, designed specifically for production environments. +Run the narrowest useful test first, then broader checks when confidence is needed. -Plugin Runtime automatically starts each installed plugin and interacts through stdio. In plugin development scenarios, developers can use the lbp command-line tool to start plugins and connect to the running Runtime via WebSocket for debugging. +## Where to Look -> Plugin SDK, CLI, Runtime, and entities definitions shared between LangBot and plugins are contained in the [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk) repository. +- Architecture map: `ARCHITECTURE.md`. +- Dev environment guide: https://docs.langbot.app/zh/develop/dev-config. +- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime. +- API-key auth: `docs/API_KEY_AUTH.md`. +- Box deep-dive notes: `docs/review/box-architecture.md` and related files. +- In-repo skills: `skills/` is the single source of truth for LangBot agent skills. +- SDK repo: `../langbot-plugin-sdk/` when changing shared entities, plugin APIs, action protocol, `lbp rt`, or `lbp box`. -## Some Development Tips and Standards +## Cross-Repo SDK Work -- LangBot is a global project, any comments in code should be in English, and user experience should be considered in all aspects. -- Thus you should consider the i18n support in all aspects. -- LangBot is widely adopted in both toC and toB scenarios, so you should consider the compatibility and security in all aspects. -- If you were asked to make a commit, please follow the commit message format: - - format: (): - - type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc. - - scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc. - - subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc. -- LangBot uses [Alembic](https://alembic.sqlalchemy.org/) to manage database migrations, supporting both SQLite and PostgreSQL. Migration files are located in `src/langbot/pkg/persistence/alembic/versions/`. If you changed the definition of database entities (ORM models), generate a new migration script by running `uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change"` in the project root (requires `data/config.yaml` to exist). Review and edit the generated script before committing. Migrations are executed automatically on LangBot startup. For data migrations (e.g. modifying JSON field content), you need to manually add the migration code in the generated script. +When changing SDK contracts used by LangBot: -## Some Principles +```bash +# from langbot-plugin-sdk, with LangBot's .venv active +uv pip install . + +# from LangBot, preserve the locally installed SDK +uv run --no-sync main.py +``` + +For standalone runtime debugging: + +```bash +# in langbot-plugin-sdk +uv run --no-sync lbp rt +uv run --no-sync lbp box + +# in LangBot +uv run --no-sync main.py --standalone-runtime +uv run --no-sync main.py --standalone-box +``` + +Config keys to verify in `data/config.yaml` / `src/langbot/templates/config.yaml`: + +- Plugin runtime: `plugin.runtime_ws_url`, default Docker host `langbot_plugin_runtime:5400/control/ws`. +- Box runtime: `box.enabled`, `box.backend`, `box.runtime.endpoint`, Docker host `langbot_box:5410`. +- API/MCP auth: `api.global_api_key`. + +## Change Rules + +- HTTP API changes that should be agent-accessible must update the matching MCP tool in `src/langbot/pkg/api/mcp/server.py` and the relevant skill under `skills/` in the same pass. +- New schema changes use Alembic under `src/langbot/pkg/persistence/alembic/versions/`; do not add legacy `dbmXXX` migrations. +- New platform behavior belongs in platform adapters only for platform translation; pipeline/business logic belongs in `pkg/pipeline/` or services. +- User-facing strings must support i18n (`en_US`, `zh_Hans`; include `ja_JP` where the repo already does). +- Code comments and docstrings must be English. +- Keep compatibility and security in mind; LangBot is used in both self-hosted/community and toB deployments. +- Commit message format: `(): `. + +## Runtime Pitfalls + +- Local stdio Plugin Runtime disconnects do not auto-reconnect; restart LangBot if that path breaks. +- Orphan runtime processes on `5400`/`5401` commonly break plugin debugging. +- Use `uv run --no-sync` after locally installing the SDK, or `uv` may restore the pinned package. +- A false Box “no backend” often means Docker is running but the current user lacks Docker socket permission. +- Do not confuse external MCP servers LangBot connects to (`pkg/provider/tools/loaders/mcp.py`) with LangBot's own `/mcp` server (`pkg/api/mcp/`). +- `CLAUDE.md` is a symlink to this file; edit `AGENTS.md`, not the symlink. + +## Principles - Keep it simple, stupid. -- Entities should not be multiplied unnecessarily +- Entities should not be multiplied unnecessarily. - 八荣八耻 以瞎猜接口为耻,以认真查询为荣。 @@ -85,4 +112,4 @@ Plugin Runtime automatically starts each installed plugin and interacts through 以跳过验证为耻,以主动测试为荣。 以破坏架构为耻,以遵循规范为荣。 以假装理解为耻,以诚实无知为荣。 - 以盲目修改为耻,以谨慎重构为荣。 \ No newline at end of file + 以盲目修改为耻,以谨慎重构为荣。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 000000000..ded90e7ea --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,250 @@ +# Architecture + +This document is a map of LangBot's moving parts. It is intentionally more stable than a feature guide and more concrete than the README: when you need to change behavior, start here, then follow the file references into the code. + +For agent-specific working rules, see `AGENTS.md`. For plugin-runtime and Box-runtime implementation details, also read the sibling SDK repo: [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk). + +## What LangBot Is + +LangBot is an open-source platform for building production IM bots backed by LLMs, agents, RAG, plugins, MCP tools, and a web management panel. + +At runtime, one LangBot process owns: + +- a Quart/Hypercorn HTTP service and the built web UI on `:5300`; +- messaging-platform adapters such as Discord, Telegram, Slack, WeChat, QQ, WeCom, Lark, DingTalk, KOOK, LINE, Satori, Matrix, and HTTP/WebSocket bots; +- a pipeline engine that turns inbound platform messages into LLM/tool/plugin work and replies; +- persistence, storage, vector database, telemetry, monitoring, and configuration managers; +- bridges to the Plugin Runtime and Box Runtime provided by `langbot-plugin-sdk`; +- an MCP server at `/mcp` exposing a curated agent-facing subset of the service layer. + +## Repository Boundary + +LangBot is not a single-repo system. + +- `LangBot/` is the main product: backend, web UI, platform adapters, pipeline engine, HTTP API, MCP server, RAG, persistence, skills integration, and the bridge code that talks to runtimes. +- `langbot-plugin-sdk/` is published as `langbot-plugin` and pinned in `LangBot/pyproject.toml`. It contains plugin developer APIs, shared entities, `lbp`, the Plugin Runtime (`lbp rt`), and the Box Runtime (`lbp box`). +- Plugins import SDK APIs from `langbot_plugin.*`; the LangBot main process imports the same package for shared entities and runtime protocols. + +This split matters. If a change modifies SDK entities, component APIs, action protocols, `lbp rt`, or `lbp box`, verify the sibling SDK repo and install the local SDK into LangBot's virtualenv when testing cross-repo behavior. + +## Startup Path + +The process entrypoint is small and layered: + +1. `main.py` delegates to `langbot.__main__.main()`. +2. `src/langbot/__main__.py` parses `--standalone-runtime`, `--standalone-box`, and `--debug`, checks dependencies, generates missing config/data files, and calls `pkg.core.boot.main()`. +3. `pkg/core/boot.py` executes startup stages in order: `LoadConfigStage`, `GenKeysStage`, `SetupLoggerStage`, `BuildAppStage`, `ShowNotesStage`. +4. `BuildAppStage` constructs the `Application` object by wiring managers, services, runtime connectors, and controllers. +5. `Application.run()` starts the platform manager, query controller, HTTP controller, telemetry/cleanup loops, and plugin initialization. + +The central runtime object is `pkg/core/app.py::Application`. It is a service locator for long-lived managers. That is not elegant, but it is the current architectural center; most subsystems receive `ap: Application` and collaborate through it. + +## Top-Level Layout + +```text +LangBot/ +├── main.py # Entrypoint shim +├── pyproject.toml # Python package, deps, pinned langbot-plugin +├── src/langbot/ +│ ├── __main__.py # CLI entrypoint and boot handoff +│ ├── pkg/ +│ │ ├── core/ # Application, boot stages, task manager +│ │ ├── api/ # HTTP API + MCP server mount +│ │ ├── platform/ # IM adapters and runtime bot manager +│ │ ├── pipeline/ # Message routing and pipeline stages +│ │ ├── provider/ # LLM runners, model manager, tools +│ │ ├── plugin/ # LangBot-side Plugin Runtime connector/handler +│ │ ├── box/ # LangBot-side Box service/connector +│ │ ├── skill/ # Skill metadata/activation integration +│ │ ├── rag/ , vector/ # Knowledge-base and vector DB integration +│ │ ├── persistence/ # SQLAlchemy/SQLModel, Alembic, legacy migrations +│ │ ├── storage/ # Local/S3 file storage abstraction +│ │ └── config/, entity/, utils/, telemetry/, survey/ +│ ├── libs/ # Vendored third-party platform SDKs +│ └── templates/ # Default config and component metadata +├── web/ # Vite + React Router + shadcn/ui + Tailwind SPA +├── docker/ # Deployment manifests +├── skills/ # In-repo agent skills, single source of truth +└── tests/ # Unit/integration/e2e/manual tests +``` + +## The Runtime Graph + +The most useful mental model is this graph: + +```text +Platform adapter + → RuntimeBot + → MessageAggregator + → QueryPool + → Controller + → RuntimePipeline + → PipelineStage chain + → RequestRunner / ToolManager / PluginRuntimeConnector / BoxService + → response via adapter +``` + +The HTTP and MCP surfaces are parallel entrypoints into the same service layer: + +```text +HTTP client / Web UI + → Quart route group + → api/http/service/* + → Application managers / persistence / runtime connectors + +MCP client + → /mcp mount + → api/mcp/server.py tools + → the same service layer directly +``` + +## Message Flow + +Inbound platform messages enter through adapter-specific SDK callbacks. The common path is: + +1. A platform adapter under `pkg/platform/sources/` converts platform-specific events into SDK message/event entities. +2. `RuntimeBot` in `pkg/platform/botmgr.py` applies pipeline routing rules and either discards the message, pushes it to webhooks, or sends it to the message aggregator. +3. `MessageAggregator` batches/normalizes messages before adding a `Query` to `QueryPool`. +4. `Controller` in `pkg/pipeline/controller.py` selects queries subject to global pipeline concurrency and per-session concurrency. +5. `RuntimePipeline` in `pkg/pipeline/pipelinemgr.py` runs configured pipeline stages using a responsibility-chain style executor that supports generator stages. +6. The chat stage emits plugin events, calls a configured `RequestRunner`, handles streaming/non-streaming responses, records telemetry, and appends conversation history. +7. Output stages send text, cards, chunks, files, or error notices back through the original platform adapter. + +Pipeline components are registered by decorators and package import side effects. When adding a new stage, loader, runner, or adapter, check the corresponding preregistration mechanism instead of inventing a second registry. + +## Platform Layer + +Platform code lives under `pkg/platform/`. + +- `botmgr.py` owns runtime bots, routing rules, event logging, webhook pushing, and adapter lifecycle. +- `sources/` contains adapter implementations. Each adapter subclasses `langbot_plugin.api.definition.abstract.platform.adapter.AbstractMessagePlatformAdapter` from the SDK. +- Platform entities such as `MessageChain`, `Image`, `At`, `Voice`, and events come from `langbot-plugin-sdk`, not from this repo. + +The platform layer should translate between external platform APIs and LangBot's shared message/event model. It should not contain LLM-provider logic or pipeline business logic. + +## Pipeline Layer + +Pipeline code lives under `pkg/pipeline/`. + +Important pieces: + +- `pool.py::QueryPool` stores pending queries and cached in-flight queries for plugin backward-compatible calls. +- `controller.py::Controller` schedules query processing and enforces concurrency. +- `pipelinemgr.py::RuntimePipeline` materializes database pipeline config into a runtime stage chain. +- `process/handlers/chat.py::ChatMessageHandler` is the main LLM conversation handler. +- Stage families include response rules, banned sessions, content filters, preprocessors, rate limits, message truncation, long text handling, response-back, command handling, and wrappers. + +Pipelines are configuration-driven. Prefer adding a stage or extending an existing stage family over hard-coding behavior in platform adapters. + +## Provider, RAG, and Tools + +Provider code lives under `pkg/provider/`. + +- `modelmgr/` manages configured model providers and requesters. +- `runners/` implements request runners such as the local agent runner and external workflow integrations. +- `tools/toolmgr.py` aggregates tools from native tools, plugin tools, external MCP servers, and skill-authoring tools. +- `tools/loaders/mcp.py` is the MCP client side: external MCP servers that LangBot connects to for agent tools. +- RAG lives across `pkg/rag/`, `pkg/vector/`, model services, and plugin KnowledgeEngine actions. + +Do not confuse LangBot's MCP client side with LangBot's own MCP server at `/mcp`; they are different surfaces. + +## Plugin System + +The plugin system crosses the repo boundary. + +In this repo: + +- `pkg/plugin/connector.py` connects LangBot to the Plugin Runtime over stdio or WebSocket. +- `pkg/plugin/handler.py` exposes LangBot actions to the runtime and calls runtime actions for plugin operations. +- `pkg/provider/tools/loaders/plugin.py` exposes plugin Tool components to LLM runners. +- Pipeline handlers emit SDK events such as normal-message events and prompt-processing events. + +In `langbot-plugin-sdk`: + +- `src/langbot_plugin/api/` defines `BasePlugin`, component base classes, message/event entities, contexts, proxies, and manifests. +- `src/langbot_plugin/runtime/` implements `lbp rt`, plugin discovery, dependency installation, process launching, and control/debug connections. +- `src/langbot_plugin/entities/io/` defines the action protocol shared by LangBot, runtime, and plugin processes. + +The Plugin Runtime supports stdio and WebSocket control transports. Direct local LangBot runs usually spawn the runtime over stdio. Containerized/standalone deployments connect over WebSocket using `plugin.runtime_ws_url` and `--standalone-runtime`. + +## Box Runtime and Skills + +Box is the sandbox subsystem used by native agent tools, stdio MCP servers, skill authoring, and managed processes. + +In this repo: + +- `pkg/box/service.py` is the application-facing facade for exec, sessions, managed processes, skill CRUD, status, reconnects, quotas, mounts, and sandbox profiles. +- `pkg/box/connector.py` connects to the Box Runtime over stdio, Windows subprocess+WebSocket, or remote WebSocket. +- `pkg/provider/tools/loaders/native.py`, `mcp_stdio.py`, and skill loaders depend on Box availability. +- `pkg/skill/manager.py` loads skills from the Box runtime, falling back to local `data/skills` when needed. + +In `langbot-plugin-sdk`: + +- `src/langbot_plugin/box/server.py` implements `lbp box` and the WebSocket endpoints on `:5410`. +- `src/langbot_plugin/box/runtime.py` owns sandbox sessions and managed processes. +- `backend.py`, `nsjail_backend.py`, and `e2b_backend.py` implement sandbox backends. +- `skill_store.py` manages skill packages from the Box side. + +Important config keys live under `box:` in `src/langbot/templates/config.yaml`: `box.enabled`, `box.backend`, `box.runtime.endpoint`, and `box.local.*`. Start LangBot with `--standalone-box` when connecting to an externally launched Box runtime. + +## HTTP API, Web UI, and MCP Server + +`pkg/api/http/controller/main.py` builds a Quart app, registers route groups, serves the built SPA, and wraps the ASGI app with the MCP dispatcher. + +- HTTP route groups live under `pkg/api/http/controller/groups/`. +- Service-layer logic lives under `pkg/api/http/service/`. +- The built web UI is served from the frontend build path with SPA fallback. +- The MCP server lives under `pkg/api/mcp/` and is mounted at `/mcp`. + +The MCP server intentionally exposes a curated subset of the API. Tools call service classes directly rather than making HTTP requests back into LangBot. + +Maintenance rule: when adding, removing, or changing an HTTP endpoint that should be agent-accessible, update the matching MCP tool and the relevant in-repo skill under `skills/` in the same pass. + +## Persistence and Configuration + +Persistence is centered on `pkg/persistence/mgr.py`. + +- SQLite is the default database; PostgreSQL is supported. +- Models live under `pkg/entity/persistence/`. +- Fresh schemas are created from metadata, then legacy migrations run up to the frozen 3.x baseline, then Alembic migrations run to head. +- New schema changes should use Alembic under `pkg/persistence/alembic/versions/`; do not extend the frozen legacy migration chain. + +Configuration starts from `src/langbot/templates/config.yaml` and is generated into `data/config.yaml` on first run. Most long-lived managers read from `ap.instance_config.data`. + +## Frontend + +The frontend lives in `web/` and is a Vite SPA using React Router 7, shadcn/ui, Tailwind CSS, and pnpm. It is not Next.js, despite some historical filenames. + +In development, `pnpm dev` serves the UI on `:3000` and reads `VITE_API_BASE_URL` to call the backend on `:5300`. In production, the built frontend is packaged into the Python distribution and served by the backend. + +Keep frontend API behavior aligned with `pkg/api/http/service/` and route groups. User-facing strings must go through the existing i18n setup. + +## Agent-Facing Surfaces + +LangBot is deliberately agent-friendly. The agent-facing surfaces are part of the architecture, not extra docs. + +- `skills/` is the single source of truth for in-repo skills. +- `pkg/api/mcp/server.py` exposes the LangBot MCP server at `/mcp`. +- `api.global_api_key` authenticates API/MCP access without a browser login. +- `AGENTS.md` and `ARCHITECTURE.md` tell coding agents how the repo works. + +When one of these changes, update the others if the behavior or contract changed. API, MCP tools, and skills are one system; drift is a bug. + +## Where to Change Things + +- New HTTP API: add/adjust a service in `pkg/api/http/service/`, a route group in `pkg/api/http/controller/groups/`, tests, and MCP/skills if agent-accessible. +- New platform adapter: add a `pkg/platform/sources/*` adapter, component metadata/templates as needed, i18n, docs, and tests/smoke coverage. +- New pipeline behavior: add or extend a pipeline stage family under `pkg/pipeline/`; avoid putting pipeline rules in adapters. +- New LLM provider/requester: work under `pkg/provider/modelmgr/` and related service/UI surfaces. +- New LLM tool source: extend `pkg/provider/tools/loaders/` and `ToolManager` intentionally. +- New plugin component/API/protocol: change `langbot-plugin-sdk` first or in lockstep, then update LangBot bridge code. +- New Box capability: change both `pkg/box/` and `langbot-plugin-sdk/src/langbot_plugin/box/`, plus config and tests. +- New database schema: add an Alembic migration, not a legacy `dbmXXX` migration. + +## Design Biases + +- Keep platform translation, pipeline orchestration, provider execution, and runtime protocols separate. +- Reuse existing registries and service layers instead of adding parallel paths. +- Prefer small, explicit agent surfaces over exposing every internal API. +- Treat cross-repo contracts with the SDK as public interfaces. +- Test behavior at the narrowest useful layer first, then add integration/e2e coverage for runtime or platform changes. diff --git a/CLA.md b/CLA.md new file mode 100644 index 000000000..ac04798e3 --- /dev/null +++ b/CLA.md @@ -0,0 +1,107 @@ +# LangBot Individual Contributor License Agreement (v1.0) + +Thank you for your interest in contributing to LangBot (the "Project"), stewarded by Beijing Langbo Intelligent Technology Co., Ltd. (北京浪波智能科技有限公司) ("We" or "Us"). + +This Individual Contributor License Agreement ("Agreement") documents the rights granted by contributors to Us. By signing this Agreement (see Section 9), You accept and agree to the following terms and conditions for Your present and future Contributions submitted to the Project. Except for the licenses granted herein to Us and recipients of software distributed by Us, You reserve all right, title, and interest in and to Your Contributions. + +## 1. Definitions + +"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with Us. + +"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to Us for inclusion in, or documentation of, any of the products or repositories owned or managed by Us (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to Us or our representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Us for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." + +## 2. Grant of Copyright License + +Subject to the terms and conditions of this Agreement, You hereby grant to Us and to recipients of software distributed by Us a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. For clarity, this includes the right for Us to distribute Your Contributions, alone or as part of the Work, under the terms of any license, including without limitation open source licenses and commercial or proprietary licenses. + +## 3. Grant of Patent License + +Subject to the terms and conditions of this Agreement, You hereby grant to Us and to recipients of software distributed by Us a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that Your Contribution, or the Work to which You have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. + +## 4. Authority; Employer + +You represent that You are legally entitled to grant the above licenses. If Your employer(s) has rights to intellectual property that You create that includes Your Contributions, You represent that You have received permission to make Contributions on behalf of that employer, that Your employer has waived such rights for Your Contributions to Us, or that Your employer has executed a separate Corporate Contributor License Agreement with Us. + +## 5. Original Creation; Disclosure + +You represent that each of Your Contributions is Your original creation (see Section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which You are personally aware and which are associated with any part of Your Contributions. + +## 6. No Obligation of Support; Disclaimer + +You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. + +## 7. Third-Party Works + +Should You wish to submit work that is not Your original creation, You may submit it to Us separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which You are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". + +## 8. Notification + +You agree to notify Us of any facts or circumstances of which You become aware that would make these representations inaccurate in any respect. + +## 9. Electronic Signature + +This Agreement is accepted and signed electronically: posting a comment containing the exact phrase designated by Us (currently "I have read the CLA Document and I hereby sign the CLA") from Your GitHub account on a pull request in the Project's repositories constitutes Your binding electronic signature to this Agreement. You represent that the GitHub account used to sign belongs to You and that You are of legal age to form a binding contract. Your signature covers Your present and future Contributions to all repositories owned or managed by Us, until and unless You notify Us in writing that You withdraw from this Agreement for future Contributions (licenses already granted are irrevocable). + +## 10. Our Commitment + +We commit that the Project's main repository will continue to make an open source version of the Work publicly available. + +## 11. Miscellaneous + +This Agreement is the entire agreement between You and Us regarding Your Contributions and supersedes any prior agreements on this subject. If any provision is held unenforceable, the remaining provisions remain in effect. This Agreement is executed in English; the Chinese translation below is provided for reference only, and the English version shall prevail in case of any discrepancy. + +--- + +# LangBot 个人贡献者许可协议(v1.0)中文参考译文 + +> 本译文仅供参考,如与英文版有任何歧义,以英文版为准。 + +感谢您有意为 LangBot(下称"本项目")作出贡献。本项目由北京浪波智能科技有限公司(下称"我方")运营管理。 + +本《个人贡献者许可协议》(下称"本协议")旨在记录贡献者授予我方的各项权利。您一经签署本协议(见第 9 条),即接受并同意以下条款与条件,适用于您向本项目提交的现在及未来的全部贡献。除本协议授予我方及我方分发软件之接收者的许可外,您保留对您的贡献的全部权利、所有权和利益。 + +## 1. 定义 + +"您"指与我方订立本协议的版权所有人,或经版权所有人授权的法律实体。 + +"贡献"指您有意提交给我方、用于纳入我方拥有或管理的任何产品或代码仓库(下称"作品")或其文档的任何原创作品,包括对既有作品的修改或增补。就本定义而言,"提交"指以任何电子、口头或书面形式向我方或我方代表发送的通信,包括但不限于在由我方或代表我方管理的电子邮件列表、源代码管理系统和问题跟踪系统中,为讨论和改进作品而进行的通信;但您以显著方式标注或以书面形式声明为"非贡献"(Not a Contribution)的通信除外。 + +## 2. 版权许可的授予 + +在遵守本协议条款与条件的前提下,您特此授予我方及我方分发软件之接收者一项永久的、全球范围的、非独占的、免费的、免版税的、不可撤销的版权许可,以复制您的贡献、基于其创作衍生作品、公开展示、公开表演、再许可以及分发您的贡献及上述衍生作品。为明确起见,上述许可包括我方有权以任何许可条款(包括但不限于开源许可证以及商业或专有许可证)单独或作为作品的一部分分发您的贡献。 + +## 3. 专利许可的授予 + +在遵守本协议条款与条件的前提下,您特此授予我方及我方分发软件之接收者一项永久的、全球范围的、非独占的、免费的、免版税的、不可撤销的(本条所述情形除外)专利许可,以制造、委托制造、使用、许诺销售、销售、进口及以其他方式转让作品;该许可仅适用于您可许可的、且因您的贡献本身或您的贡献与其所提交之作品的结合而必然受到侵犯的专利权利要求。如任何实体对您或任何其他实体提起专利诉讼(包括诉讼中的交叉请求或反诉),主张您的贡献或您所贡献的作品构成直接或帮助性专利侵权,则依据本协议就该贡献或作品授予该实体的任何专利许可,自该诉讼提起之日起终止。 + +## 4. 权利能力与雇主 + +您声明您在法律上有权授予上述许可。如您的雇主对您创作的、包含您的贡献在内的知识产权享有权利,您声明:您已获得该雇主代表其作出贡献的许可,或该雇主已就您向我方的贡献放弃上述权利,或该雇主已与我方另行签署《企业贡献者许可协议》。 + +## 5. 原创性声明与披露义务 + +您声明您的每项贡献均为您的原创作品(代表第三方提交的情形见第 7 条)。您声明您提交的贡献中已完整披露您本人知悉的、与您的贡献任何部分相关的任何第三方许可或其他限制(包括但不限于相关专利和商标)的全部细节。 + +## 6. 无支持义务;免责声明 + +您无义务为您的贡献提供支持,除非您自愿提供。您可以免费提供支持、收费提供支持或不提供支持。除非适用法律要求或另有书面约定,您的贡献按"现状"(AS IS)提供,不附带任何明示或默示的保证或条件,包括但不限于关于权属、不侵权、适销性或特定用途适用性的任何保证或条件。 + +## 7. 第三方作品 + +如您希望提交非您原创的作品,您可以将其与任何贡献分开单独提交给我方,并完整说明其来源以及您本人知悉的任何许可或其他限制(包括但不限于相关专利、商标和许可协议)的全部细节,同时以显著方式将该作品标注为"代表第三方提交:[此处注明第三方名称]"。 + +## 8. 通知义务 + +如您知悉任何事实或情况将导致上述声明在任何方面不准确,您同意通知我方。 + +## 9. 电子签署 + +本协议以电子方式接受并签署:您通过您的 GitHub 账号,在本项目代码仓库的拉取请求(pull request)中发表包含我方指定语句(现为 "I have read the CLA Document and I hereby sign the CLA")的评论,即构成您对本协议具有约束力的电子签名。您声明用于签署的 GitHub 账号归您本人所有,且您已达到订立有约束力合同的法定年龄。您的签署覆盖您对我方拥有或管理的全部代码仓库的现在及未来的贡献,直至您以书面形式通知我方就未来贡献退出本协议为止(已授予的许可不可撤销)。 + +## 10. 我方承诺 + +我方承诺本项目主仓库将持续公开提供作品的开源版本。 + +## 11. 其他 + +本协议构成您与我方之间就您的贡献达成的完整协议,并取代双方先前就此主题达成的任何协议。如本协议任何条款被认定为不可执行,其余条款仍然有效。本协议以英文签署,中文译文仅供参考,如有歧义以英文版为准。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f7dd073a..a3fc22fa1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,12 @@ - 在 PR 和 Commit Message 中请使用全英文 - 对于中文用户,issue 中可以使用中文 +### 贡献者许可协议(CLA) + +为了保护项目和每一位贡献者,我们要求所有代码贡献者签署[贡献者许可协议(CLA)](./CLA.md)。这是 Apache、Google、Grafana 等主流开源项目的标准做法:您保留自己代码的全部版权,仅授予项目使用、分发您贡献的许可。 + +签署只需 10 秒:首次提交 PR 时,机器人会自动评论提示,按提示回复一句话即完成签署,此后对本组织所有仓库永久有效。历史贡献不受影响。 +
## Guidelines @@ -29,3 +35,9 @@ - Use English in PRs and Commit Messages - For English users, you can use English in issues + +### Contributor License Agreement (CLA) + +To protect the project and every contributor, we require all code contributors to sign our [Contributor License Agreement](./CLA.md). This is standard practice in major open source projects such as Apache, Google, and Grafana: you keep full copyright of your code — the CLA only grants us a license to use and distribute your contribution. + +Signing takes 10 seconds: when you open your first PR, a bot will guide you to reply with a single comment. One signature covers all repositories in this organization, permanently. Past contributions are not affected. diff --git a/Dockerfile b/Dockerfile index 961330da0..99fce8f2b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,25 @@ COPY web ./web RUN cd web && npm install && npx vite build +# Build nsjail from source so the image ships a self-contained sandbox backend +# that needs no host Docker socket. Pinned to a release tag for reproducibility. +# Multi-stage keeps the compile toolchain (bison/flex/protobuf-dev/libnl-dev) +# out of the final image; only the nsjail binary and its small runtime libs +# (libprotobuf, libnl-route-3) are carried over. +FROM python:3.12.7-slim AS nsjail-build + +ARG NSJAIL_VERSION=3.6 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates git build-essential \ + autoconf bison flex libtool pkg-config \ + protobuf-compiler libprotobuf-dev libnl-route-3-dev \ + && git clone --depth 1 --branch "${NSJAIL_VERSION}" https://github.com/google/nsjail.git /nsjail \ + && make -C /nsjail \ + && install -m 0755 /nsjail/nsjail /usr/local/bin/nsjail \ + && rm -rf /var/lib/apt/lists/* + FROM python:3.12.7-slim WORKDIR /app @@ -14,10 +33,38 @@ COPY . . COPY --from=node /app/web/dist ./web/dist -RUN apt update \ - && apt install gcc -y \ +# nsjail binary built in the dedicated stage above. Self-contained sandbox +# backend; lets the Box runtime isolate code without a host Docker socket. +COPY --from=nsjail-build /usr/local/bin/nsjail /usr/local/bin/nsjail + +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc ca-certificates curl gnupg \ + # nsjail runtime libraries (the build toolchain stays in the nsjail-build + # stage; only these shared libs are needed to execute the binary). + && apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 \ + # Install the Docker CLI (client only) so the optional langbot_box + # service can drive the mounted host Docker socket and create sandbox + # containers. The same image powers langbot / plugin_runtime / box; only + # box uses the client. Arch-aware via dpkg so multi-arch builds work. + && install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ + && chmod a+r /etc/apt/keyrings/docker.asc \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" > /etc/apt/sources.list.d/docker.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends docker-ce-cli \ + # Install Node.js LTS so the sandbox (nsjail/Docker box) can run npx-based + # stdio MCP servers. node/npx land in /usr/bin, which is on the nsjail + # read-only mount whitelist (_READONLY_SYSTEM_MOUNTS), so they are bound + # into the sandbox chroot automatically. Without node, any npx-launched + # MCP server exits with return_code=127 (command not found). + && curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh \ + && bash /tmp/nodesource_setup.sh \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -f /tmp/nodesource_setup.sh \ && python -m pip install --no-cache-dir uv \ && uv sync \ + && apt-get purge -y --auto-remove curl gnupg \ + && rm -rf /var/lib/apt/lists/* \ && touch /.dockerenv CMD [ "uv", "run", "--no-sync", "main.py" ] \ No newline at end of file diff --git a/README.md b/README.md index 52ea870fb..f56ce7f56 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@
-LangBot - Production-grade IM bot made easy. | Product Hunt +LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt

Production-grade platform for building agentic IM bots.

Quickly build, debug, and ship AI bots to Slack, Discord, Telegram, WeChat, and more.

@@ -36,9 +36,13 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本 LangBot is an **open-source, production-grade platform** for building AI-powered instant messaging bots. It connects Large Language Models (LLMs) to any chat platform, enabling you to create intelligent agents that can converse, execute tasks, and integrate with your existing workflows. +

+LangBot web management dashboard — real-time monitoring of message volume, model calls, success rate and active sessions +

+ ### Key Capabilities -- **AI Conversations & Agents** — Multi-turn dialogues, tool calling, multi-modal support, streaming output. Built-in RAG (knowledge base) with deep integration to [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org). +- **AI Conversations & Agents** — Multi-turn dialogues, tool calling, multi-modal support, streaming output. Built-in RAG (knowledge base) with deep integration to [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com). - **Universal IM Platform Support** — One codebase for Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. - **Production-Ready** — Access control, rate limiting, sensitive word filtering, comprehensive monitoring, and exception handling. Trusted by enterprises. - **Plugin Ecosystem** — Hundreds of plugins, event-driven architecture, component extensions, and [MCP protocol](https://modelcontextprotocol.io/) support. @@ -47,10 +51,16 @@ LangBot is an **open-source, production-grade platform** for building AI-powered [→ Learn more about all features](https://link.langbot.app/en/docs/features) -📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). +📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). --- +## 😎 Stay Updated + +Click the Star and Watch buttons in the top-right corner of the repository to get the latest updates. + +![star gif](https://langbot.app/star.gif) + ## Quick Start ### ☁️ LangBot Cloud (Recommended) @@ -70,7 +80,7 @@ uvx langbot ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker -docker compose up -d +docker compose --profile all up -d ``` ### One-Click Cloud Deploy @@ -78,7 +88,7 @@ docker compose up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) +**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) --- @@ -126,7 +136,7 @@ docker compose up -d | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ | | [接口 AI](https://jiekou.ai/) | Gateway | ✅ | -| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ | [→ View all integrations](https://link.langbot.app/en/docs/features) @@ -144,6 +154,19 @@ docker compose up -d --- +## Built for AI Agents 🤖 + +LangBot is **agent-friendly by design** — your coding agents (Claude Code, Codex, Copilot, Cursor, …) can operate, extend, and deploy LangBot with first-class support: + +- **MCP Server** — LangBot exposes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) endpoint at `/mcp`, mirroring the HTTP API so an agent can manage bots, pipelines, plugins, and models programmatically. Authenticate with the same API key (set a global key in `config.yaml` or use a per-user key) — no login flow required. Configure it in the Web panel's **API & MCP** tab. +- **In-repo Skills** — The [`skills/`](skills/) directory is the **single source of truth** for working with LangBot: plugin development, core development, end-to-end testing, deployment, and operating the LangBot / LangBot Space MCP servers. Point your agent at this directory and it knows how to build. +- **AGENTS.md** — Every repo ships an [`AGENTS.md`](AGENTS.md) (symlinked to `CLAUDE.md`) describing architecture, conventions, and the rule that API changes must keep the MCP server and skills in sync. +- **`llms.txt`** — Machine-readable project context for LLMs is published on the website. + +> **Cloud / Marketplace:** [LangBot Space](https://space.langbot.app) also exposes an MCP server so agents can search and inspect the plugin / MCP / skill marketplace, authenticated with a Personal Access Token. + +--- + ## Live Demo **Try it now:** https://demo.langbot.dev/ diff --git a/README_CN.md b/README_CN.md index c735fcc17..ab0d5f235 100644 --- a/README_CN.md +++ b/README_CN.md @@ -13,7 +13,7 @@ [English](README.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) -[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-1030838208-blue)](https://qm.qq.com/q/DxZZcNxM1W) +[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-1030838208-blue)](https://qm.qq.com/q/IrlV8QFacU) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) python @@ -25,7 +25,7 @@ 文档APICloud | -插件市场 | +扩展市场路线图
@@ -36,9 +36,13 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型(LLM)连接到各种聊天平台,帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。 +

+LangBot Web 管理面板仪表盘 — 实时监控消息量、模型调用、成功率与活跃会话 +

+ ### 核心能力 -- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。 +- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、[Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com)等 LLMOps 平台。 - **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。 - **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。 - **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。 @@ -47,10 +51,16 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时 [→ 了解更多功能特性](https://link.langbot.app/zh/docs/features) -📍 实践指南:[5 分钟部署多平台 AI 机器人](https://blog.langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://blog.langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://blog.langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://blog.langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。 +📍 实践指南:[5 分钟部署多平台 AI 机器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。 --- +## 😎 保持更新 + +点击[仓库首页](https://github.com/langbot-app/LangBot)右上角 Star 和 Watch 按钮,获取最新动态。 + +![star gif](https://langbot.app/star.gif) + ## 快速开始 ### ☁️ LangBot Cloud(推荐) @@ -70,7 +80,7 @@ uvx langbot ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker -docker compose up -d +docker compose --profile all up -d ``` ### 一键云部署 @@ -78,7 +88,7 @@ docker compose up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) +**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes) --- @@ -126,7 +136,7 @@ docker compose up -d | [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ | | [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ | -| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | 聚合平台 | ✅ | | [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ | | [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ | | [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ | @@ -170,6 +180,19 @@ docker compose up -d --- +## 为 AI Agent 而生 🤖 + +LangBot **从设计上就对 Agent 友好** —— 你的编码 Agent(Claude Code、Codex、Copilot、Cursor 等)可以一等公民般地操作、扩展和部署 LangBot: + +- **MCP Server** —— LangBot 内置 [Model Context Protocol](https://modelcontextprotocol.io/) 端点 `/mcp`,与 HTTP API 对齐,Agent 可编程式管理机器人、流水线、插件和模型。使用同一套 API Key 鉴权(可在 `config.yaml` 配置全局 Key,或使用用户 Key),无需登录流程。在 Web 面板的 **API 与 MCP** 标签页中配置。 +- **仓库内 Skills** —— [`skills/`](skills/) 目录是使用 LangBot 的**唯一事实来源**:插件开发、核心开发、端到端测试、部署,以及操作 LangBot / LangBot Space MCP Server。把 Agent 指向这个目录,它就知道如何动手。 +- **AGENTS.md** —— 每个仓库都提供 [`AGENTS.md`](AGENTS.md)(软链到 `CLAUDE.md`),描述架构、规范,以及「API 变更必须同步更新 MCP Server 和 skills」的约定。 +- **`llms.txt`** —— 面向 LLM 的机器可读项目上下文已发布在官网。 + +> **云端 / 市场:** [LangBot Space](https://space.langbot.app) 同样开放 MCP Server,Agent 可搜索和查看插件 / MCP / Skill 市场,使用 Personal Access Token 鉴权。 + +--- + ## 社区 [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) diff --git a/README_ES.md b/README_ES.md index b57fa1039..73061fcbd 100644 --- a/README_ES.md +++ b/README_ES.md @@ -5,7 +5,7 @@
-LangBot - Production-grade IM bot made easy. | Product Hunt +LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt

Plataforma de grado de producción para construir bots de mensajería instantánea con agentes de IA.

Construya, depure y despliegue bots de IA rápidamente en Slack, Discord, Telegram, WeChat y más.

@@ -35,9 +35,13 @@ LangBot es una **plataforma de código abierto y grado de producción** para construir bots de mensajería instantánea impulsados por IA. Conecta modelos de lenguaje de gran escala (LLMs) con cualquier plataforma de chat, permitiéndole crear agentes inteligentes que pueden conversar, ejecutar tareas e integrarse con sus flujos de trabajo existentes. +

+Panel de gestión web de LangBot — monitoreo en tiempo real de volumen de mensajes, llamadas a modelos, tasa de éxito y sesiones activas +

+ ### Capacidades Clave -- **Conversaciones e Agentes IA** — Diálogos de múltiples turnos, llamadas a herramientas, soporte multimodal, salida en streaming. RAG (base de conocimientos) incorporado con integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org). +- **Conversaciones e Agentes IA** — Diálogos de múltiples turnos, llamadas a herramientas, soporte multimodal, salida en streaming. RAG (base de conocimientos) incorporado con integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com). - **Soporte Universal de Plataformas de MI** — Un solo código base para Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. - **Listo para Producción** — Control de acceso, limitación de velocidad, filtrado de palabras sensibles, monitoreo completo y manejo de excepciones. De confianza para empresas. - **Ecosistema de Plugins** — Cientos de plugins, arquitectura basada en eventos, extensiones de componentes y soporte del [protocolo MCP](https://modelcontextprotocol.io/). @@ -46,10 +50,16 @@ LangBot es una **plataforma de código abierto y grado de producción** para con [→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features) -📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). +📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). --- +## 😎 Manténgase Actualizado + +Haga clic en los botones Star y Watch en la esquina superior derecha del repositorio para obtener las últimas actualizaciones. + +![star gif](https://langbot.app/star.gif) + ## Inicio Rápido ### ☁️ LangBot Cloud (Recomendado) @@ -69,7 +79,7 @@ uvx langbot ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker -docker compose up -d +docker compose --profile all up -d ``` ### Despliegue en la Nube con un Clic @@ -77,7 +87,7 @@ docker compose up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) +**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) --- @@ -125,7 +135,7 @@ docker compose up -d | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plataforma GPU | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ | | [接口 AI](https://jiekou.ai/) | Pasarela | ✅ | -| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ | [→ Ver todas las integraciones](https://link.langbot.app/en/docs/features) @@ -151,6 +161,17 @@ docker compose up -d *Nota: Entorno de demostración público. No ingrese información confidencial.* +## Diseñado para Agentes de IA 🤖 + +LangBot es **agent-friendly por diseño** —— tus agentes de codificación (Claude Code, Codex, Copilot, Cursor, …) pueden operar, extender y desplegar LangBot con soporte de primera clase: + +- **Servidor MCP** —— LangBot expone un endpoint integrado de [Model Context Protocol](https://modelcontextprotocol.io/) en `/mcp`, replicando la API HTTP para que un agente gestione bots, pipelines, plugins y modelos de forma programática. Autentícate con la misma API key (configura una clave global en `config.yaml` o usa una clave por usuario) —— sin flujo de login. Configúralo en la pestaña **API & MCP** del panel web. +- **Skills en el repositorio** —— El directorio [`skills/`](skills/) es la **única fuente de verdad** para trabajar con LangBot: desarrollo de plugins, desarrollo del core, pruebas end-to-end, despliegue y operación de los servidores MCP de LangBot / LangBot Space. Apunta tu agente a este directorio y sabrá cómo construir. +- **AGENTS.md** —— Cada repo incluye un [`AGENTS.md`](AGENTS.md) (enlazado simbólicamente a `CLAUDE.md`) que describe la arquitectura, las convenciones y la regla de que los cambios en la API deben mantener sincronizados el servidor MCP y los skills. +- **`llms.txt`** —— El contexto del proyecto legible por máquina para LLMs está publicado en el sitio web. + +> **Nube / Marketplace:** [LangBot Space](https://space.langbot.app) también expone un servidor MCP para que los agentes busquen e inspeccionen el marketplace de plugins / MCP / skills, autenticados con un Personal Access Token. + --- ## Comunidad diff --git a/README_FR.md b/README_FR.md index be44aa093..8c0b032fa 100644 --- a/README_FR.md +++ b/README_FR.md @@ -5,7 +5,7 @@
-LangBot - Production-grade IM bot made easy. | Product Hunt +LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt

Plateforme de niveau production pour construire des bots de messagerie instantanée avec agents IA.

Créez, déboguez et déployez rapidement des bots IA sur Slack, Discord, Telegram, WeChat et plus.

@@ -35,9 +35,13 @@ LangBot est une **plateforme open-source de niveau production** pour créer des bots de messagerie instantanée alimentés par l'IA. Elle connecte les grands modèles de langage (LLMs) à n'importe quelle plateforme de chat, vous permettant de créer des agents intelligents capables de converser, d'exécuter des tâches et de s'intégrer à vos workflows existants. +

+Tableau de bord de gestion web LangBot — surveillance en temps réel du volume de messages, des appels de modèles, du taux de réussite et des sessions actives +

+ ### Capacités Clés -- **Conversations IA & Agents** — Dialogues multi-tours, appels d'outils, support multimodal, sortie en streaming. RAG (base de connaissances) intégré avec intégration profonde de [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org). +- **Conversations IA & Agents** — Dialogues multi-tours, appels d'outils, support multimodal, sortie en streaming. RAG (base de connaissances) intégré avec intégration profonde de [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com). - **Support Universel des Plateformes de MI** — Un seul code pour Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. - **Prêt pour la Production** — Contrôle d'accès, limitation de débit, filtrage de mots sensibles, surveillance complète et gestion des exceptions. Approuvé par les entreprises. - **Écosystème de Plugins** — Des centaines de plugins, architecture événementielle, extensions de composants, et support du [protocole MCP](https://modelcontextprotocol.io/). @@ -46,10 +50,16 @@ LangBot est une **plateforme open-source de niveau production** pour créer des [→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features) -📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). +📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). --- +## 😎 Restez à Jour + +Cliquez sur les boutons Star et Watch dans le coin supérieur droit du dépôt pour obtenir les dernières mises à jour. + +![star gif](https://langbot.app/star.gif) + ## Démarrage Rapide ### ☁️ LangBot Cloud (Recommandé) @@ -69,7 +79,7 @@ uvx langbot ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker -docker compose up -d +docker compose --profile all up -d ``` ### Déploiement Cloud en un Clic @@ -77,7 +87,7 @@ docker compose up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) +**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) --- @@ -122,7 +132,7 @@ docker compose up -d | [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Passerelle | ✅ | | [GiteeAI](https://ai.gitee.com/) | Passerelle | ✅ | | [接口 AI](https://jiekou.ai/) | Passerelle | ✅ | -| [302.AI](https://share.302.ai/SuTG99) | Passerelle | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | Passerelle | ✅ | | [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ | @@ -151,6 +161,17 @@ docker compose up -d *Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.* +## Conçu pour les agents IA 🤖 + +LangBot est **agent-friendly par conception** —— vos agents de codage (Claude Code, Codex, Copilot, Cursor, …) peuvent exploiter, étendre et déployer LangBot avec un support de premier ordre : + +- **Serveur MCP** —— LangBot expose un endpoint [Model Context Protocol](https://modelcontextprotocol.io/) intégré sur `/mcp`, reflétant l'API HTTP pour qu'un agent gère bots, pipelines, plugins et modèles de façon programmatique. Authentifiez-vous avec la même clé API (définissez une clé globale dans `config.yaml` ou utilisez une clé par utilisateur) —— sans flux de connexion. Configurez-le dans l'onglet **API & MCP** du panneau web. +- **Skills dans le dépôt** —— Le répertoire [`skills/`](skills/) est la **source unique de vérité** pour travailler avec LangBot : développement de plugins, développement du cœur, tests de bout en bout, déploiement et exploitation des serveurs MCP de LangBot / LangBot Space. Pointez votre agent vers ce répertoire et il saura construire. +- **AGENTS.md** —— Chaque dépôt fournit un [`AGENTS.md`](AGENTS.md) (lien symbolique vers `CLAUDE.md`) décrivant l'architecture, les conventions et la règle selon laquelle les changements d'API doivent garder le serveur MCP et les skills synchronisés. +- **`llms.txt`** —— Le contexte projet lisible par machine pour les LLM est publié sur le site web. + +> **Cloud / Marketplace :** [LangBot Space](https://space.langbot.app) expose également un serveur MCP pour que les agents recherchent et inspectent le marketplace de plugins / MCP / skills, authentifiés avec un Personal Access Token. + --- ## Communauté diff --git a/README_JP.md b/README_JP.md index 098d796d4..10d765fcd 100644 --- a/README_JP.md +++ b/README_JP.md @@ -5,7 +5,7 @@
-LangBot - Production-grade IM bot made easy. | Product Hunt +LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt

AIエージェント搭載IMボットを構築するための本番グレードプラットフォーム。

Slack、Discord、Telegram、WeChat などに AI ボットを素早く構築、デバッグ、デプロイ。

@@ -35,9 +35,13 @@ LangBot は、AI搭載のインスタントメッセージングボットを構築するための**オープンソースの本番グレードプラットフォーム**です。大規模言語モデル(LLM)をあらゆるチャットプラットフォームに接続し、会話、タスク実行、既存のワークフローとの統合が可能なインテリジェントエージェントを作成できます。 +

+LangBot Web 管理パネルのダッシュボード — メッセージ量、モデル呼び出し、成功率、アクティブセッションをリアルタイム監視 +

+ ### 主な機能 -- **AI対話とエージェント** — マルチターン対話、ツール呼び出し、マルチモーダル対応、ストリーミング出力。RAG(ナレッジベース)を内蔵し、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) と深く統合。 +- **AI対話とエージェント** — マルチターン対話、ツール呼び出し、マルチモーダル対応、ストリーミング出力。RAG(ナレッジベース)を内蔵し、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、[Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com) と深く統合。 - **ユニバーサルIMプラットフォーム対応** — 単一のコードベースで Discord、Telegram、Slack、LINE、QQ、WeChat、WeCom、Lark、DingTalk、KOOK に対応。 - **本番環境対応** — アクセス制御、レート制限、センシティブワードフィルタリング、包括的な監視、例外処理を搭載。エンタープライズの信頼に応える品質。 - **プラグインエコシステム** — 数百のプラグイン、イベント駆動アーキテクチャ、コンポーネント拡張、[MCPプロトコル](https://modelcontextprotocol.io/)対応。 @@ -46,10 +50,16 @@ LangBot は、AI搭載のインスタントメッセージングボットを構 [→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features) -📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。 +📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。 --- +## 😎 最新情報を入手 + +リポジトリの右上にある Star と Watch ボタンをクリックして、最新の更新を取得してください。 + +![star gif](https://langbot.app/star.gif) + ## クイックスタート ### ☁️ LangBot Cloud(推奨) @@ -69,7 +79,7 @@ uvx langbot ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker -docker compose up -d +docker compose --profile all up -d ``` ### ワンクリッククラウドデプロイ @@ -77,7 +87,7 @@ docker compose up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) +**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) --- @@ -125,7 +135,7 @@ docker compose up -d | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPUプラットフォーム | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ | | [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ | -| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ | [→ すべての統合を表示](https://link.langbot.app/en/docs/features) @@ -151,6 +161,17 @@ docker compose up -d *注意: 公開デモ環境です。機密情報を入力しないでください。* +## AI エージェントのために 🤖 + +LangBot は **設計段階からエージェントフレンドリー** です。お使いのコーディングエージェント(Claude Code、Codex、Copilot、Cursor など)が、ファーストクラスのサポートで LangBot を操作・拡張・デプロイできます: + +- **MCP サーバー** —— LangBot は組み込みの [Model Context Protocol](https://modelcontextprotocol.io/) エンドポイント `/mcp` を公開し、HTTP API とミラーリングされているため、エージェントがボット・パイプライン・プラグイン・モデルをプログラム的に管理できます。同じ API キーで認証(`config.yaml` でグローバルキーを設定、またはユーザーキーを使用)—— ログインフロー不要。Web パネルの **API & MCP** タブで設定します。 +- **リポジトリ内 Skills** —— [`skills/`](skills/) ディレクトリは LangBot を扱うための**唯一の信頼できる情報源**です:プラグイン開発、コア開発、E2E テスト、デプロイ、LangBot / LangBot Space MCP サーバーの操作。エージェントをこのディレクトリに向ければ、構築方法を理解します。 +- **AGENTS.md** —— すべてのリポジトリに [`AGENTS.md`](AGENTS.md)(`CLAUDE.md` へのシンボリックリンク)があり、アーキテクチャ・規約、そして「API 変更時は MCP サーバーと skills を同期する」というルールを記述しています。 +- **`llms.txt`** —— LLM 向けの機械可読なプロジェクトコンテキストを公式サイトで公開しています。 + +> **クラウド / マーケット:** [LangBot Space](https://space.langbot.app) も MCP サーバーを公開しており、エージェントが Personal Access Token で認証してプラグイン / MCP / Skill マーケットを検索・確認できます。 + --- ## コミュニティ diff --git a/README_KO.md b/README_KO.md index e699a53e4..2a4b19e6a 100644 --- a/README_KO.md +++ b/README_KO.md @@ -5,7 +5,7 @@
-LangBot - Production-grade IM bot made easy. | Product Hunt +LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt

AI 에이전트 IM 봇 구축을 위한 프로덕션 등급 플랫폼.

Slack, Discord, Telegram, WeChat 등에 AI 봇을 빠르게 구축, 디버그 및 배포.

@@ -35,9 +35,13 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈소스 프로덕션 등급 플랫폼**입니다. 대규모 언어 모델(LLM)을 모든 채팅 플랫폼에 연결하여 대화, 작업 실행, 기존 워크플로우와의 통합이 가능한 지능형 에이전트를 만들 수 있습니다. +

+LangBot 웹 관리 패널 대시보드 — 메시지 양, 모델 호출, 성공률, 활성 세션 실시간 모니터링 +

+ ### 핵심 기능 -- **AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) 심층 통합. +- **AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com) 심층 통합. - **유니버설 IM 플랫폼 지원** — 단일 코드베이스로 Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK 지원. - **프로덕션 레디** — 접근 제어, 속도 제한, 민감어 필터링, 종합 모니터링 및 예외 처리. 기업 환경에서 검증됨. - **플러그인 생태계** — 수백 개의 플러그인, 이벤트 기반 아키텍처, 컴포넌트 확장, [MCP 프로토콜](https://modelcontextprotocol.io/) 지원. @@ -46,10 +50,16 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈 [→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features) -📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). +📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). --- +## 😎 최신 정보 받기 + +리포지토리 오른쪽 상단의 Star 및 Watch 버튼을 클릭하여 최신 업데이트를 받으세요. + +![star gif](https://langbot.app/star.gif) + ## 빠른 시작 ### ☁️ LangBot Cloud (추천) @@ -69,7 +79,7 @@ uvx langbot ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker -docker compose up -d +docker compose --profile all up -d ``` ### 원클릭 클라우드 배포 @@ -77,7 +87,7 @@ docker compose up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) +**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) --- @@ -125,7 +135,7 @@ docker compose up -d | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 플랫폼 | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ | | [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ | -| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ | [→ 모든 통합 보기](https://link.langbot.app/en/docs/features) @@ -151,6 +161,17 @@ docker compose up -d *참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.* +## AI 에이전트를 위한 설계 🤖 + +LangBot은 **설계 단계부터 에이전트 친화적**입니다 —— 코딩 에이전트(Claude Code, Codex, Copilot, Cursor 등)가 일급 지원으로 LangBot을 운영·확장·배포할 수 있습니다: + +- **MCP 서버** —— LangBot은 내장 [Model Context Protocol](https://modelcontextprotocol.io/) 엔드포인트 `/mcp`를 제공하며, HTTP API와 동일하게 미러링되어 에이전트가 봇·파이프라인·플러그인·모델을 프로그래밍 방식으로 관리할 수 있습니다. 동일한 API 키로 인증하며(`config.yaml`에 전역 키 설정 또는 사용자 키 사용) 로그인 절차가 필요 없습니다. 웹 패널의 **API & MCP** 탭에서 설정합니다. +- **저장소 내 Skills** —— [`skills/`](skills/) 디렉터리는 LangBot 작업의 **단일 진실 공급원**입니다: 플러그인 개발, 코어 개발, E2E 테스트, 배포, LangBot / LangBot Space MCP 서버 운영. 에이전트를 이 디렉터리로 안내하면 빌드 방법을 알게 됩니다. +- **AGENTS.md** —— 모든 저장소에는 [`AGENTS.md`](AGENTS.md)(`CLAUDE.md`로 심볼릭 링크)가 있으며 아키텍처, 규약, 그리고 API 변경 시 MCP 서버와 skills를 동기화해야 한다는 규칙을 설명합니다. +- **`llms.txt`** —— LLM을 위한 기계 판독 가능한 프로젝트 컨텍스트가 웹사이트에 게시되어 있습니다. + +> **클라우드 / 마켓플레이스:** [LangBot Space](https://space.langbot.app)도 MCP 서버를 제공하여 에이전트가 Personal Access Token으로 인증해 플러그인 / MCP / Skill 마켓플레이스를 검색하고 조회할 수 있습니다. + --- ## 커뮤니티 diff --git a/README_RU.md b/README_RU.md index 1ca28b19e..ef2fed6be 100644 --- a/README_RU.md +++ b/README_RU.md @@ -5,7 +5,7 @@
-LangBot - Production-grade IM bot made easy. | Product Hunt +LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt

Платформа производственного уровня для создания агентных IM-ботов.

Быстро создавайте, отлаживайте и развертывайте ИИ-ботов в Slack, Discord, Telegram, WeChat и других платформах.

@@ -35,9 +35,13 @@ LangBot — это **платформа с открытым исходным кодом производственного уровня** для создания ИИ-ботов в мессенджерах. Она связывает большие языковые модели (LLM) с любой чат-платформой, позволяя создавать интеллектуальных агентов, которые могут вести диалоги, выполнять задачи и интегрироваться с вашими существующими рабочими процессами. +

+Панель веб-управления LangBot — мониторинг объёма сообщений, вызовов моделей, успешности и активных сессий в реальном времени +

+ ### Ключевые возможности -- **ИИ-диалоги и агенты** — Многораундовые диалоги, вызов инструментов, мультимодальная поддержка, потоковый вывод. Встроенная реализация RAG (база знаний) с глубокой интеграцией в [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org). +- **ИИ-диалоги и агенты** — Многораундовые диалоги, вызов инструментов, мультимодальная поддержка, потоковый вывод. Встроенная реализация RAG (база знаний) с глубокой интеграцией в [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com). - **Универсальная поддержка IM-платформ** — Единая кодовая база для Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. - **Готовность к продакшену** — Контроль доступа, ограничение скорости, фильтрация чувствительных слов, комплексный мониторинг и обработка исключений. Проверено в корпоративной среде. - **Экосистема плагинов** — Сотни плагинов, событийно-ориентированная архитектура, расширения компонентов и поддержка [протокола MCP](https://modelcontextprotocol.io/). @@ -46,10 +50,16 @@ LangBot — это **платформа с открытым исходным к [→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features) -📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). +📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). --- +## 😎 Оставайтесь в курсе + +Нажмите кнопки Star и Watch в правом верхнем углу репозитория, чтобы получать последние обновления. + +![star gif](https://langbot.app/star.gif) + ## Быстрый старт ### ☁️ LangBot Cloud (Рекомендуется) @@ -69,7 +79,7 @@ uvx langbot ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker -docker compose up -d +docker compose --profile all up -d ``` ### Облачное развертывание одним кликом @@ -77,7 +87,7 @@ docker compose up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) +**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) --- @@ -121,7 +131,7 @@ docker compose up -d | [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Шлюз | ✅ | | [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Шлюз | ✅ | | [GiteeAI](https://ai.gitee.com/) | Шлюз | ✅ | -| [302.AI](https://share.302.ai/SuTG99) | Шлюз | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | Шлюз | ✅ | | [接口 AI](https://jiekou.ai/) | Шлюз | ✅ | | [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ | @@ -151,6 +161,17 @@ docker compose up -d *Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.* +## Создано для ИИ-агентов 🤖 + +LangBot **дружелюбен к агентам по своей архитектуре** —— ваши кодинг-агенты (Claude Code, Codex, Copilot, Cursor и др.) могут управлять, расширять и развёртывать LangBot с первоклассной поддержкой: + +- **MCP-сервер** —— LangBot предоставляет встроенную конечную точку [Model Context Protocol](https://modelcontextprotocol.io/) по адресу `/mcp`, зеркалирующую HTTP API, чтобы агент мог программно управлять ботами, пайплайнами, плагинами и моделями. Аутентификация той же API-ключом (задайте глобальный ключ в `config.yaml` или используйте пользовательский ключ) —— без процедуры входа. Настраивается на вкладке **API & MCP** веб-панели. +- **Skills в репозитории** —— Каталог [`skills/`](skills/) является **единственным источником истины** для работы с LangBot: разработка плагинов, разработка ядра, сквозное тестирование, развёртывание и работа с MCP-серверами LangBot / LangBot Space. Направьте агента в этот каталог, и он будет знать, как собирать. +- **AGENTS.md** —— Каждый репозиторий содержит [`AGENTS.md`](AGENTS.md) (символическая ссылка на `CLAUDE.md`), описывающий архитектуру, соглашения и правило: изменения API должны синхронизировать MCP-сервер и skills. +- **`llms.txt`** —— Машиночитаемый контекст проекта для LLM опубликован на сайте. + +> **Облако / Маркетплейс:** [LangBot Space](https://space.langbot.app) также предоставляет MCP-сервер, чтобы агенты могли искать и просматривать маркетплейс плагинов / MCP / skills, аутентифицируясь с помощью Personal Access Token. + --- ## Сообщество diff --git a/README_TW.md b/README_TW.md index 2fdf5a856..9d741e97a 100644 --- a/README_TW.md +++ b/README_TW.md @@ -37,9 +37,13 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時通訊機器人。它將大語言模型(LLM)連接到各種聊天平台,幫助你創建能夠對話、執行任務、並整合到現有工作流程中的智能 Agent。 +

+LangBot Web 管理面板儀表板 — 即時監控訊息量、模型調用、成功率與活躍工作階段 +

+ ### 核心能力 -- **AI 對話與 Agent** — 多輪對話、工具調用、多模態、流式輸出。自帶 RAG(知識庫),深度整合 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。 +- **AI 對話與 Agent** — 多輪對話、工具調用、多模態、流式輸出。自帶 RAG(知識庫),深度整合 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、 [Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com)等 LLMOps 平台。 - **全平台支援** — 一套程式碼,覆蓋 QQ、微信、企業微信、飛書、釘釘、Discord、Telegram、Slack、LINE、KOOK 等平台。 - **生產就緒** — 存取控制、限速、敏感詞過濾、全面監控與異常處理,已被多家企業採用。 - **外掛生態** — 數百個外掛,事件驅動架構,組件擴展,適配 [MCP 協議](https://modelcontextprotocol.io/)。 @@ -48,10 +52,16 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時 [→ 了解更多功能特性](https://link.langbot.app/zh/docs/features) -📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://blog.langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://blog.langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://blog.langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://blog.langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。 +📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。 --- +## 😎 保持更新 + +點擊倉庫右上角 Star 和 Watch 按鈕,獲取最新動態。 + +![star gif](https://langbot.app/star.gif) + ## 快速開始 ### ☁️ LangBot Cloud(推薦) @@ -71,7 +81,7 @@ uvx langbot ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker -docker compose up -d +docker compose --profile all up -d ``` ### 一鍵雲端部署 @@ -79,7 +89,7 @@ docker compose up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) +**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes) --- @@ -127,7 +137,7 @@ docker compose up -d | [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ | | [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ | -| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | 聚合平台 | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ | ### TTS(語音合成) @@ -167,6 +177,17 @@ docker compose up -d *注意:公開演示環境,請不要在其中填入任何敏感資訊。* +## 為 AI Agent 而生 🤖 + +LangBot **從設計上就對 Agent 友善** —— 你的編碼 Agent(Claude Code、Codex、Copilot、Cursor 等)可以一等公民般地操作、擴充和部署 LangBot: + +- **MCP Server** —— LangBot 內建 [Model Context Protocol](https://modelcontextprotocol.io/) 端點 `/mcp`,與 HTTP API 對齊,Agent 可程式化管理機器人、流水線、外掛和模型。使用同一套 API Key 鑑權(可在 `config.yaml` 設定全域 Key,或使用使用者 Key),無需登入流程。在 Web 面板的 **API 與 MCP** 分頁中設定。 +- **倉庫內 Skills** —— [`skills/`](skills/) 目錄是使用 LangBot 的**唯一事實來源**:外掛開發、核心開發、端到端測試、部署,以及操作 LangBot / LangBot Space MCP Server。把 Agent 指向這個目錄,它就知道如何動手。 +- **AGENTS.md** —— 每個倉庫都提供 [`AGENTS.md`](AGENTS.md)(軟連結到 `CLAUDE.md`),描述架構、規範,以及「API 變更必須同步更新 MCP Server 和 skills」的約定。 +- **`llms.txt`** —— 面向 LLM 的機器可讀專案上下文已發布在官網。 + +> **雲端 / 市集:** [LangBot Space](https://space.langbot.app) 同樣開放 MCP Server,Agent 可搜尋和檢視外掛 / MCP / Skill 市集,使用 Personal Access Token 鑑權。 + --- ## 社群 diff --git a/README_VI.md b/README_VI.md index 7fbf62479..d1cdbd9d9 100644 --- a/README_VI.md +++ b/README_VI.md @@ -5,7 +5,7 @@
-LangBot - Production-grade IM bot made easy. | Product Hunt +LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt

Nền tảng cấp sản xuất để xây dựng bot IM với AI agent.

Xây dựng, gỡ lỗi và triển khai bot AI nhanh chóng trên Slack, Discord, Telegram, WeChat và nhiều nền tảng khác.

@@ -35,9 +35,13 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để xây dựng bot nhắn tin tức thời được hỗ trợ bởi AI. Nó kết nối các Mô hình Ngôn ngữ Lớn (LLM) với bất kỳ nền tảng chat nào, cho phép bạn tạo các agent thông minh có thể trò chuyện, thực hiện tác vụ và tích hợp với quy trình làm việc hiện có của bạn. +

+Bảng điều khiển quản lý web LangBot — giám sát thời gian thực khối lượng tin nhắn, lệnh gọi mô hình, tỷ lệ thành công và phiên hoạt động +

+ ### Khả năng chính -- **Hội thoại AI & Agent** — Đối thoại nhiều lượt, gọi công cụ, hỗ trợ đa phương thức, đầu ra streaming. RAG (cơ sở kiến thức) tích hợp sẵn với tích hợp sâu vào [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org). +- **Hội thoại AI & Agent** — Đối thoại nhiều lượt, gọi công cụ, hỗ trợ đa phương thức, đầu ra streaming. RAG (cơ sở kiến thức) tích hợp sẵn với tích hợp sâu vào [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com). - **Hỗ trợ đa nền tảng IM** — Một mã nguồn cho Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. - **Sẵn sàng cho sản xuất** — Kiểm soát truy cập, giới hạn tốc độ, lọc từ nhạy cảm, giám sát toàn diện và xử lý ngoại lệ. Được doanh nghiệp tin dùng. - **Hệ sinh thái Plugin** — Hàng trăm plugin, kiến trúc hướng sự kiện, mở rộng thành phần, và hỗ trợ [giao thức MCP](https://modelcontextprotocol.io/). @@ -46,10 +50,16 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x [→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features) -📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). +📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). --- +## 😎 Cập nhật Mới nhất + +Nhấp vào các nút Star và Watch ở góc trên bên phải của kho lưu trữ để nhận các bản cập nhật mới nhất. + +![star gif](https://langbot.app/star.gif) + ## Bắt đầu nhanh ### ☁️ LangBot Cloud (Khuyên dùng) @@ -69,7 +79,7 @@ uvx langbot ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker -docker compose up -d +docker compose --profile all up -d ``` ### Triển khai đám mây một cú nhấp @@ -77,7 +87,7 @@ docker compose up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) +**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) --- @@ -125,7 +135,7 @@ docker compose up -d | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Nền tảng GPU | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ | | [接口 AI](https://jiekou.ai/) | Cổng | ✅ | -| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ | [→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features) @@ -151,6 +161,17 @@ docker compose up -d *Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.* +## Được xây dựng cho AI Agent 🤖 + +LangBot **thân thiện với agent ngay từ thiết kế** —— các coding agent của bạn (Claude Code, Codex, Copilot, Cursor, …) có thể vận hành, mở rộng và triển khai LangBot với sự hỗ trợ hạng nhất: + +- **MCP Server** —— LangBot cung cấp endpoint [Model Context Protocol](https://modelcontextprotocol.io/) tích hợp tại `/mcp`, phản chiếu HTTP API để agent quản lý bot, pipeline, plugin và model theo cách lập trình. Xác thực bằng cùng một API key (đặt key toàn cục trong `config.yaml` hoặc dùng key theo người dùng) —— không cần luồng đăng nhập. Cấu hình tại tab **API & MCP** trong bảng điều khiển Web. +- **Skills trong repo** —— Thư mục [`skills/`](skills/) là **nguồn sự thật duy nhất** để làm việc với LangBot: phát triển plugin, phát triển core, kiểm thử end-to-end, triển khai và vận hành MCP Server của LangBot / LangBot Space. Trỏ agent của bạn vào thư mục này và nó sẽ biết cách xây dựng. +- **AGENTS.md** —— Mỗi repo đều có [`AGENTS.md`](AGENTS.md) (liên kết tượng trưng tới `CLAUDE.md`) mô tả kiến trúc, quy ước và quy tắc rằng thay đổi API phải giữ MCP Server và skills đồng bộ. +- **`llms.txt`** —— Ngữ cảnh dự án có thể đọc bằng máy dành cho LLM được công bố trên website. + +> **Cloud / Marketplace:** [LangBot Space](https://space.langbot.app) cũng cung cấp MCP Server để agent tìm kiếm và kiểm tra marketplace plugin / MCP / skill, xác thực bằng Personal Access Token. + --- ## Cộng đồng diff --git a/docker/README_K8S.md b/docker/README_K8S.md deleted file mode 100644 index 06743eca1..000000000 --- a/docker/README_K8S.md +++ /dev/null @@ -1,629 +0,0 @@ -# LangBot Kubernetes 部署指南 / Kubernetes Deployment Guide - -[简体中文](#简体中文) | [English](#english) - ---- - -## 简体中文 - -### 概述 - -本指南提供了在 Kubernetes 集群中部署 LangBot 的完整步骤。Kubernetes 部署配置基于 `docker-compose.yaml`,适用于生产环境的容器化部署。 - -### 前置要求 - -- Kubernetes 集群(版本 1.19+) -- `kubectl` 命令行工具已配置并可访问集群 -- 集群中有可用的存储类(StorageClass)用于持久化存储(可选但推荐) -- 至少 2 vCPU 和 4GB RAM 的可用资源 - -### 架构说明 - -Kubernetes 部署包含以下组件: - -1. **langbot**: 主应用服务 - - 提供 Web UI(端口 5300) - - 处理平台 webhook(端口 2280-2290) - - 数据持久化卷 - -2. **langbot-plugin-runtime**: 插件运行时服务 - - WebSocket 通信(端口 5400) - - 插件数据持久化卷 - -3. **持久化存储**: - - `langbot-data`: LangBot 主数据 - - `langbot-plugins`: 插件文件 - - `langbot-plugin-runtime-data`: 插件运行时数据 - -### 快速开始 - -#### 1. 下载部署文件 - -```bash -# 克隆仓库 -git clone https://github.com/langbot-app/LangBot -cd LangBot/docker - -# 或直接下载 kubernetes.yaml -wget https://raw.githubusercontent.com/langbot-app/LangBot/main/docker/kubernetes.yaml -``` - -#### 2. 部署到 Kubernetes - -```bash -# 应用所有配置 -kubectl apply -f kubernetes.yaml - -# 检查部署状态 -kubectl get all -n langbot - -# 查看 Pod 日志 -kubectl logs -n langbot -l app=langbot -f -``` - -#### 3. 访问 LangBot - -默认情况下,LangBot 服务使用 ClusterIP 类型,只能在集群内部访问。您可以选择以下方式之一来访问: - -**选项 A: 端口转发(推荐用于测试)** - -```bash -kubectl port-forward -n langbot svc/langbot 5300:5300 -``` - -然后访问 http://localhost:5300 - -**选项 B: NodePort(适用于开发环境)** - -编辑 `kubernetes.yaml`,取消注释 NodePort Service 部分,然后: - -```bash -kubectl apply -f kubernetes.yaml -# 获取节点 IP -kubectl get nodes -o wide -# 访问 http://:30300 -``` - -**选项 C: LoadBalancer(适用于云环境)** - -编辑 `kubernetes.yaml`,取消注释 LoadBalancer Service 部分,然后: - -```bash -kubectl apply -f kubernetes.yaml -# 获取外部 IP -kubectl get svc -n langbot langbot-loadbalancer -# 访问 http:// -``` - -**选项 D: Ingress(推荐用于生产环境)** - -确保集群中已安装 Ingress Controller(如 nginx-ingress),然后: - -1. 编辑 `kubernetes.yaml` 中的 Ingress 配置 -2. 修改域名为您的实际域名 -3. 应用配置: - -```bash -kubectl apply -f kubernetes.yaml -# 访问 http://langbot.yourdomain.com -``` - -### 配置说明 - -#### 环境变量 - -在 `ConfigMap` 中配置环境变量: - -```yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: langbot-config - namespace: langbot -data: - TZ: "Asia/Shanghai" # 修改为您的时区 -``` - -#### 存储配置 - -默认使用动态存储分配。如果您有特定的 StorageClass,请在 PVC 中指定: - -```yaml -spec: - storageClassName: your-storage-class-name - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 10Gi -``` - -#### 资源限制 - -根据您的需求调整资源限制: - -```yaml -resources: - requests: - memory: "1Gi" - cpu: "500m" - limits: - memory: "4Gi" - cpu: "2000m" -``` - -### 常用操作 - -#### 查看日志 - -```bash -# 查看 LangBot 主服务日志 -kubectl logs -n langbot -l app=langbot -f - -# 查看插件运行时日志 -kubectl logs -n langbot -l app=langbot-plugin-runtime -f -``` - -#### 重启服务 - -```bash -# 重启 LangBot -kubectl rollout restart deployment/langbot -n langbot - -# 重启插件运行时 -kubectl rollout restart deployment/langbot-plugin-runtime -n langbot -``` - -#### 更新镜像 - -```bash -# 更新到最新版本 -kubectl set image deployment/langbot -n langbot langbot=rockchin/langbot:latest -kubectl set image deployment/langbot-plugin-runtime -n langbot langbot-plugin-runtime=rockchin/langbot:latest - -# 检查更新状态 -kubectl rollout status deployment/langbot -n langbot -``` - -#### 扩容(不推荐) - -注意:由于 LangBot 使用 ReadWriteOnce 的持久化存储,不支持多副本扩容。如需高可用,请考虑使用 ReadWriteMany 存储或其他架构方案。 - -#### 备份数据 - -```bash -# 备份 PVC 数据 -kubectl exec -n langbot -it -- tar czf /tmp/backup.tar.gz /app/data -kubectl cp langbot/:/tmp/backup.tar.gz ./backup.tar.gz -``` - -### 卸载 - -```bash -# 删除所有资源(保留 PVC) -kubectl delete deployment,service,configmap -n langbot --all - -# 删除 PVC(会删除数据) -kubectl delete pvc -n langbot --all - -# 删除命名空间 -kubectl delete namespace langbot -``` - -### 故障排查 - -#### Pod 无法启动 - -```bash -# 查看 Pod 状态 -kubectl get pods -n langbot - -# 查看详细信息 -kubectl describe pod -n langbot - -# 查看事件 -kubectl get events -n langbot --sort-by='.lastTimestamp' -``` - -#### 存储问题 - -```bash -# 检查 PVC 状态 -kubectl get pvc -n langbot - -# 检查 PV -kubectl get pv -``` - -#### 网络访问问题 - -```bash -# 检查 Service -kubectl get svc -n langbot - -# 检查端口转发 -kubectl port-forward -n langbot svc/langbot 5300:5300 -``` - -### 生产环境建议 - -1. **使用特定版本标签**:避免使用 `latest` 标签,使用具体版本号如 `rockchin/langbot:v1.0.0` -2. **配置资源限制**:根据实际负载调整 CPU 和内存限制 -3. **使用 Ingress + TLS**:配置 HTTPS 访问和证书管理 -4. **配置监控和告警**:集成 Prometheus、Grafana 等监控工具 -5. **定期备份**:配置自动备份策略保护数据 -6. **使用专用 StorageClass**:为生产环境配置高性能存储 -7. **配置亲和性规则**:确保 Pod 调度到合适的节点 - -### 高级配置 - -#### 使用 Secrets 管理敏感信息 - -如果需要配置 API 密钥等敏感信息: - -```yaml -apiVersion: v1 -kind: Secret -metadata: - name: langbot-secrets - namespace: langbot -type: Opaque -data: - api_key: -``` - -然后在 Deployment 中引用: - -```yaml -env: -- name: API_KEY - valueFrom: - secretKeyRef: - name: langbot-secrets - key: api_key -``` - -#### 配置水平自动扩缩容(HPA) - -注意:需要确保使用 ReadWriteMany 存储类型 - -```yaml -apiVersion: autoscaling/v2 -kind: HorizontalPodAutoscaler -metadata: - name: langbot-hpa - namespace: langbot -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: langbot - minReplicas: 1 - maxReplicas: 3 - metrics: - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: 70 -``` - -### 参考资源 - -- [LangBot 官方文档](https://docs.langbot.app) -- [Docker 部署文档](https://link.langbot.app/zh/docs/docker) -- [Kubernetes 官方文档](https://kubernetes.io/docs/) - ---- - -## English - -### Overview - -This guide provides complete steps for deploying LangBot in a Kubernetes cluster. The Kubernetes deployment configuration is based on `docker-compose.yaml` and is suitable for production containerized deployments. - -### Prerequisites - -- Kubernetes cluster (version 1.19+) -- `kubectl` command-line tool configured with cluster access -- Available StorageClass in the cluster for persistent storage (optional but recommended) -- At least 2 vCPU and 4GB RAM of available resources - -### Architecture - -The Kubernetes deployment includes the following components: - -1. **langbot**: Main application service - - Provides Web UI (port 5300) - - Handles platform webhooks (ports 2280-2290) - - Data persistence volume - -2. **langbot-plugin-runtime**: Plugin runtime service - - WebSocket communication (port 5400) - - Plugin data persistence volume - -3. **Persistent Storage**: - - `langbot-data`: LangBot main data - - `langbot-plugins`: Plugin files - - `langbot-plugin-runtime-data`: Plugin runtime data - -### Quick Start - -#### 1. Download Deployment Files - -```bash -# Clone repository -git clone https://github.com/langbot-app/LangBot -cd LangBot/docker - -# Or download kubernetes.yaml directly -wget https://raw.githubusercontent.com/langbot-app/LangBot/main/docker/kubernetes.yaml -``` - -#### 2. Deploy to Kubernetes - -```bash -# Apply all configurations -kubectl apply -f kubernetes.yaml - -# Check deployment status -kubectl get all -n langbot - -# View Pod logs -kubectl logs -n langbot -l app=langbot -f -``` - -#### 3. Access LangBot - -By default, LangBot service uses ClusterIP type, accessible only within the cluster. Choose one of the following methods to access: - -**Option A: Port Forwarding (Recommended for testing)** - -```bash -kubectl port-forward -n langbot svc/langbot 5300:5300 -``` - -Then visit http://localhost:5300 - -**Option B: NodePort (Suitable for development)** - -Edit `kubernetes.yaml`, uncomment the NodePort Service section, then: - -```bash -kubectl apply -f kubernetes.yaml -# Get node IP -kubectl get nodes -o wide -# Visit http://:30300 -``` - -**Option C: LoadBalancer (Suitable for cloud environments)** - -Edit `kubernetes.yaml`, uncomment the LoadBalancer Service section, then: - -```bash -kubectl apply -f kubernetes.yaml -# Get external IP -kubectl get svc -n langbot langbot-loadbalancer -# Visit http:// -``` - -**Option D: Ingress (Recommended for production)** - -Ensure an Ingress Controller (e.g., nginx-ingress) is installed in the cluster, then: - -1. Edit the Ingress configuration in `kubernetes.yaml` -2. Change the domain to your actual domain -3. Apply configuration: - -```bash -kubectl apply -f kubernetes.yaml -# Visit http://langbot.yourdomain.com -``` - -### Configuration - -#### Environment Variables - -Configure environment variables in ConfigMap: - -```yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: langbot-config - namespace: langbot -data: - TZ: "Asia/Shanghai" # Change to your timezone -``` - -#### Storage Configuration - -Uses dynamic storage provisioning by default. If you have a specific StorageClass, specify it in PVC: - -```yaml -spec: - storageClassName: your-storage-class-name - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 10Gi -``` - -#### Resource Limits - -Adjust resource limits based on your needs: - -```yaml -resources: - requests: - memory: "1Gi" - cpu: "500m" - limits: - memory: "4Gi" - cpu: "2000m" -``` - -### Common Operations - -#### View Logs - -```bash -# View LangBot main service logs -kubectl logs -n langbot -l app=langbot -f - -# View plugin runtime logs -kubectl logs -n langbot -l app=langbot-plugin-runtime -f -``` - -#### Restart Services - -```bash -# Restart LangBot -kubectl rollout restart deployment/langbot -n langbot - -# Restart plugin runtime -kubectl rollout restart deployment/langbot-plugin-runtime -n langbot -``` - -#### Update Images - -```bash -# Update to latest version -kubectl set image deployment/langbot -n langbot langbot=rockchin/langbot:latest -kubectl set image deployment/langbot-plugin-runtime -n langbot langbot-plugin-runtime=rockchin/langbot:latest - -# Check update status -kubectl rollout status deployment/langbot -n langbot -``` - -#### Scaling (Not Recommended) - -Note: Due to LangBot using ReadWriteOnce persistent storage, multi-replica scaling is not supported. For high availability, consider using ReadWriteMany storage or alternative architectures. - -#### Backup Data - -```bash -# Backup PVC data -kubectl exec -n langbot -it -- tar czf /tmp/backup.tar.gz /app/data -kubectl cp langbot/:/tmp/backup.tar.gz ./backup.tar.gz -``` - -### Uninstall - -```bash -# Delete all resources (keep PVCs) -kubectl delete deployment,service,configmap -n langbot --all - -# Delete PVCs (will delete data) -kubectl delete pvc -n langbot --all - -# Delete namespace -kubectl delete namespace langbot -``` - -### Troubleshooting - -#### Pods Not Starting - -```bash -# Check Pod status -kubectl get pods -n langbot - -# View detailed information -kubectl describe pod -n langbot - -# View events -kubectl get events -n langbot --sort-by='.lastTimestamp' -``` - -#### Storage Issues - -```bash -# Check PVC status -kubectl get pvc -n langbot - -# Check PV -kubectl get pv -``` - -#### Network Access Issues - -```bash -# Check Service -kubectl get svc -n langbot - -# Test port forwarding -kubectl port-forward -n langbot svc/langbot 5300:5300 -``` - -### Production Recommendations - -1. **Use specific version tags**: Avoid using `latest` tag, use specific version like `rockchin/langbot:v1.0.0` -2. **Configure resource limits**: Adjust CPU and memory limits based on actual load -3. **Use Ingress + TLS**: Configure HTTPS access and certificate management -4. **Configure monitoring and alerts**: Integrate monitoring tools like Prometheus, Grafana -5. **Regular backups**: Configure automated backup strategy to protect data -6. **Use dedicated StorageClass**: Configure high-performance storage for production -7. **Configure affinity rules**: Ensure Pods are scheduled to appropriate nodes - -### Advanced Configuration - -#### Using Secrets for Sensitive Information - -If you need to configure sensitive information like API keys: - -```yaml -apiVersion: v1 -kind: Secret -metadata: - name: langbot-secrets - namespace: langbot -type: Opaque -data: - api_key: -``` - -Then reference in Deployment: - -```yaml -env: -- name: API_KEY - valueFrom: - secretKeyRef: - name: langbot-secrets - key: api_key -``` - -#### Configure Horizontal Pod Autoscaling (HPA) - -Note: Requires ReadWriteMany storage type - -```yaml -apiVersion: autoscaling/v2 -kind: HorizontalPodAutoscaler -metadata: - name: langbot-hpa - namespace: langbot -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: langbot - minReplicas: 1 - maxReplicas: 3 - metrics: - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: 70 -``` - -### References - -- [LangBot Official Documentation](https://docs.langbot.app) -- [Docker Deployment Guide](https://link.langbot.app/zh/docs/docker) -- [Kubernetes Official Documentation](https://kubernetes.io/docs/) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index d3ba8ad90..bdd347021 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,5 +1,5 @@ # Docker Compose configuration for LangBot -# For Kubernetes deployment, see kubernetes.yaml and README_K8S.md +# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app version: "3" services: @@ -18,6 +18,40 @@ services: networks: - langbot_network + # The Box sandbox runtime is optional. It is only started when you run + # ``docker compose --profile box up`` (or ``docker compose --profile all + # up``). With Box off, LangBot keeps the dashboard / skills list visible + # (read-only) but disables sandbox tools, skill add/edit and stdio MCP — + # set ``box.enabled: false`` in ``data/config.yaml`` (or + # ``BOX__ENABLED=false`` in the langbot service env below) to match. + langbot_box: + image: rockchin/langbot:latest + container_name: langbot_box + profiles: ["box", "all"] + volumes: + # Keep the source and target path identical because langbot_box uses the + # host Docker socket to create sandbox containers. Override + # LANGBOT_BOX_ROOT with an absolute path if you do not want the default. + - ${LANGBOT_BOX_ROOT:-${PWD}/data/box}:${LANGBOT_BOX_ROOT:-${PWD}/data/box} + # Mount container runtime socket for Box sandbox backend. + # Uncomment the one that matches your container runtime: + # - /var/run/podman/podman.sock:/var/run/podman/podman.sock # Podman + - /var/run/docker.sock:/var/run/docker.sock # Docker + restart: on-failure + environment: + - TZ=Asia/Shanghai + # The Box runtime does NOT read box.local.* from config.yaml or env; it + # receives its configuration from LangBot via the INIT RPC action. + # Do not add LANGBOT_BOX_* / BOX__* here — they would be silently ignored. + # Launched through the same CLI entry point as the plugin runtime + # (`langbot_plugin.cli.__init__ `). WebSocket is the default + # control transport — mirrors `rt`, which also runs with no flag. Pass + # `-s` / `--stdio-control` only for the stdio mode LangBot uses outside + # containers. + command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "box"] + networks: + - langbot_network + langbot: image: rockchin/langbot:latest container_name: langbot @@ -26,6 +60,14 @@ services: restart: on-failure environment: - TZ=Asia/Shanghai + # Unified env-override convention: SECTION__SUBSECTION__KEY overrides the + # matching config.yaml field (see LoadConfigStage). These map onto + # box.* and are forwarded to the Box runtime via INIT RPC. + - BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box} + - BOX__LOCAL__DEFAULT_WORKSPACE=default + - BOX__LOCAL__SKILLS_ROOT=skills + - BOX__LOCAL__ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box} + - BOX__DOCKER__CPU_LIMIT_ENABLED=${LANGBOT_BOX_DOCKER_CPU_LIMIT_ENABLED:-true} ports: - 5300:5300 # For web ui and webhook callback - 2280-2285:2280-2285 # For platform reverse connection @@ -34,4 +76,4 @@ services: networks: langbot_network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/docker/kubernetes.yaml b/docker/kubernetes.yaml index 424c18eb8..6adc50510 100644 --- a/docker/kubernetes.yaml +++ b/docker/kubernetes.yaml @@ -1,6 +1,8 @@ # Kubernetes Deployment for LangBot # This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml -# +# +# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes +# # Usage: # kubectl apply -f kubernetes.yaml # @@ -8,13 +10,15 @@ # - A Kubernetes cluster (1.19+) # - kubectl configured to communicate with your cluster # - (Optional) A StorageClass for dynamic volume provisioning +# - For the Box sandbox runtime: a node with a reachable Docker daemon +# (the box mounts the node's /var/run/docker.sock). See the deployment guide. # # Components: # - Namespace: langbot # - PersistentVolumeClaims for data persistence -# - Deployments for langbot and langbot_plugin_runtime +# - Deployments for langbot, langbot-plugin-runtime, and langbot-box (sandbox) # - Services for network access -# - ConfigMap for timezone configuration +# - ConfigMap for timezone + runtime endpoints --- # Namespace @@ -83,6 +87,11 @@ metadata: data: TZ: "Asia/Shanghai" PLUGIN__RUNTIME_WS_URL: "ws://langbot-plugin-runtime:5400/control/ws" + # Box sandbox runtime endpoint. LangBot connects to the Box runtime over + # WebSocket. The hostname MUST match the langbot-box Service name. Note the + # in-container default ("langbot_box") uses an underscore, which is an + # invalid Kubernetes DNS name — so the endpoint is always set explicitly here. + BOX__RUNTIME__ENDPOINT: "ws://langbot-box:5410" --- # Deployment for LangBot Plugin Runtime @@ -169,6 +178,136 @@ spec: protocol: TCP name: runtime +--- +# Deployment for LangBot Box (sandbox) runtime +# +# The Box runtime backs LangBot's sandbox tools (exec / read / write / edit / +# glob / grep), the `activate` skill tool, skill add/edit, and stdio-mode MCP +# servers. It is OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on +# the langbot Deployment (or `box.enabled: false` in config.yaml) so the +# dashboard renders cleanly with sandbox features disabled. +# +# IMPORTANT — how the sandbox actually runs: +# The bundled image ships only the Docker CLI (no dockerd, no nsjail). The Box +# runtime therefore creates sandbox containers by talking to a Docker daemon +# over the mounted socket (`/var/run/docker.sock`). Because that daemon +# resolves bind-mount paths on the NODE filesystem, the Box workspace root +# must be the SAME absolute path inside the box container, inside every +# sandbox container it spawns, AND on the node. That is why this manifest uses +# a hostPath at a fixed absolute path (/app/data/box) and pins langbot + box +# to the same node via podAffinity. A normal PVC will NOT work for the box +# workspace, because the node's dockerd cannot see paths that exist only +# inside the pod's mount namespace. +# +# Security note: mounting the host Docker socket grants the Box runtime (and any +# code executed in the sandbox) effective root on the node. Only deploy Box on +# nodes you trust for this workload, ideally a dedicated node pool. For a +# stronger isolation boundary, switch box.backend to 'e2b' (set E2B_API_KEY) and +# drop the docker.sock mount + hostPath entirely. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: langbot-box + namespace: langbot + labels: + app: langbot-box +spec: + replicas: 1 + selector: + matchLabels: + app: langbot-box + template: + metadata: + labels: + app: langbot-box + spec: + # Pin to the same node as langbot so they share the hostPath box root. + affinity: + podAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchLabels: + app: langbot + topologyKey: kubernetes.io/hostname + containers: + - name: langbot-box + image: rockchin/langbot:latest + imagePullPolicy: Always + # Launched through the same CLI entry point as the plugin runtime. + # No flag => WebSocket control transport (default), listening on 5410. + command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "box"] + ports: + - containerPort: 5410 + name: box-rpc + protocol: TCP + env: + - name: TZ + valueFrom: + configMapKeyRef: + name: langbot-config + key: TZ + # The Box runtime does NOT read box.local.* / BOX__* from its own env; + # it receives its configuration from LangBot via the INIT RPC action. + # Do not add BOX__* here — they would be silently ignored. + volumeMounts: + # Box workspace root — identical path on node, box, and sandbox + # containers (see the IMPORTANT note above). + - name: box-root + mountPath: /app/data/box + # Host Docker socket — the sandbox backend uses it to create containers. + - name: docker-sock + mountPath: /var/run/docker.sock + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "1Gi" + cpu: "1000m" + livenessProbe: + tcpSocket: + port: 5410 + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + tcpSocket: + port: 5410 + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + volumes: + - name: box-root + hostPath: + path: /app/data/box + type: DirectoryOrCreate + - name: docker-sock + hostPath: + path: /var/run/docker.sock + type: Socket + restartPolicy: Always + +--- +# Service for LangBot Box runtime +apiVersion: v1 +kind: Service +metadata: + name: langbot-box + namespace: langbot + labels: + app: langbot-box +spec: + type: ClusterIP + selector: + app: langbot-box + ports: + - port: 5410 + targetPort: 5410 + protocol: TCP + name: box-rpc + --- # Deployment for LangBot apiVersion: apps/v1 @@ -213,11 +352,36 @@ spec: configMapKeyRef: name: langbot-config key: PLUGIN__RUNTIME_WS_URL + # Box (sandbox) runtime endpoint. Connects LangBot to the langbot-box + # Service over WebSocket. Remove this (and the langbot-box Deployment) + # and set BOX__ENABLED=false if you do not want the sandbox. + - name: BOX__RUNTIME__ENDPOINT + valueFrom: + configMapKeyRef: + name: langbot-config + key: BOX__RUNTIME__ENDPOINT + # box.local.* config — forwarded to the Box runtime via INIT RPC. The + # host_root MUST match the box-root hostPath mountPath below AND the box + # Deployment's box-root mountPath, so that skill package paths resolve + # identically on both sides and on the node's Docker daemon. + - name: BOX__LOCAL__HOST_ROOT + value: "/app/data/box" + - name: BOX__LOCAL__DEFAULT_WORKSPACE + value: "default" + - name: BOX__LOCAL__SKILLS_ROOT + value: "skills" + - name: BOX__LOCAL__ALLOWED_MOUNT_ROOTS + value: "/app/data/box" volumeMounts: - name: data mountPath: /app/data - name: plugins mountPath: /app/plugins + # Same node-level box root as the langbot-box Deployment. Mounted over + # the data PVC's /app/data/box subpath so both LangBot and the Box + # runtime (and the node's dockerd) agree on one absolute path. + - name: box-root + mountPath: /app/data/box resources: requests: memory: "1Gi" @@ -250,6 +414,13 @@ spec: - name: plugins persistentVolumeClaim: claimName: langbot-plugins + # Node-level box workspace root, shared with the langbot-box Deployment. + # hostPath (not PVC) because the node's Docker daemon must see the same + # absolute path when bind-mounting workspaces into sandbox containers. + - name: box-root + hostPath: + path: /app/data/box + type: DirectoryOrCreate restartPolicy: Always --- diff --git a/docs/API_KEY_AUTH.md b/docs/API_KEY_AUTH.md index 3aa0d363e..ad3bb6693 100644 --- a/docs/API_KEY_AUTH.md +++ b/docs/API_KEY_AUTH.md @@ -10,6 +10,38 @@ API keys can be managed through the web interface: 2. Click the "API Keys" button at the bottom of the sidebar 3. Create, view, copy, or delete API keys as needed +## Global API Key (config.yaml) + +In addition to web-UI-created keys (stored in the database, prefixed `lbk_`), +LangBot supports a **global API key** defined directly in `data/config.yaml`. +This is useful for automated deployments, infrastructure-as-code, and AI agents +that need API/MCP access **without a login session and without creating a +database record first**. + +```yaml +api: + port: 5300 + # ... + global_api_key: 'your-strong-secret-here' # leave empty to disable +``` + +Behavior: + +- When `api.global_api_key` is a non-empty string, that exact value is accepted + anywhere a normal API key is accepted — the `X-API-Key` header or + `Authorization: Bearer ` — across the HTTP service API **and the MCP + server**. +- The global key does **not** require the `lbk_` prefix; use any sufficiently + strong secret. +- Leave it empty (`''`, the default) to disable it entirely; only database-backed + `lbk_` keys will then be accepted. +- Existing installs are unaffected until you add the key — config completion only + backfills top-level keys, and the lookup is defensive when the field is absent. + +> **Security:** the global key is stored in plaintext in `config.yaml`. Only +> enable it on trusted/internal deployments, keep the file permissions tight, +> always serve over HTTPS, and rotate the value if it may have leaked. + ## Using API Keys ### Authentication Headers diff --git a/docs/HTTP_BOT_ADAPTER_DESIGN.md b/docs/HTTP_BOT_ADAPTER_DESIGN.md new file mode 100644 index 000000000..31e9a4861 --- /dev/null +++ b/docs/HTTP_BOT_ADAPTER_DESIGN.md @@ -0,0 +1,575 @@ +# HTTP Bot Adapter — Design Document + +> Status: **Implemented** · Branch: `feat/http-bot-adapter` · Author: LangBot core +> +> A first-class, **standalone** message-platform adapter (`http_bot`) that lets +> any external system (e.g. LangBot Space ticketing, an internal back-office, a +> CRM, a custom web app) talk to a LangBot pipeline over plain HTTP — **inbound** +> by POSTing messages in, **outbound** by receiving replies on a callback URL — +> with full support for the pipeline's native N→1 aggregation and 1→M +> multi-reply semantics, and **without** holding a long-lived WebSocket +> connection. +> +> **Shipped in this branch:** +> - `src/langbot/pkg/platform/sources/http_bot.yaml` — adapter manifest (auto-discovered) +> - `src/langbot/pkg/platform/sources/http_bot.py` — `HttpBotAdapter` +> - `src/langbot/pkg/platform/sources/http_bot_signing.py` — HMAC helpers +> - `src/langbot/pkg/platform/sources/http_bot.svg` — icon +> - `docs/platforms/http-bot.md` — integration guide +> - `docs/http-bot-openapi.json` — machine-readable contract +> - `examples/http-bot/` — Python + TypeScript reference clients +> +> **Final decisions (resolving the original open questions):** +> 1. Callback URL is **config-only** — never accepted per-message (SSRF closed). +> 2. **Session reset is provided** — `POST /bots//reset` keyed by `session_id`. +> 3. Reference **clients are provided** — `examples/http-bot/client.py` + `client.ts`. +> 4. **Sync convenience mode is included** — `POST /bots//sync` (opt-in, lossy). + +--- + +## 1. Background & Motivation + +### 1.1 The concrete need + +LangBot Space wants to use a LangBot pipeline as the brain for **ticket +handling**. The integration is **server-to-server**: Space's backend pushes a +user's ticket messages into LangBot and renders LangBot's replies back into the +ticket thread. + +This interaction is **not** request/response shaped: + +- **N → 1**: a user may fire several messages in a row ("the app crashed" … + "when I click export" … "here's a screenshot"). The pipeline's + **message aggregation** feature should debounce and merge these into one turn. +- **1 → N**: a single turn may yield **multiple** outbound messages — a tool/ + function call narrating progress, a plugin emitting several cards, a streamed + answer split into chunks. + +### 1.2 Why the existing options don't fit + +LangBot today exposes exactly one externally-reachable way to drive a pipeline +that is **not** tied to a specific IM vendor: the **WebSocket** path +(`/api/v1/pipelines//ws/connect` for dashboard debug, and +`/api/v1/embed//ws/connect` for the embeddable web widget). + +For a server-to-server integration the WebSocket path has real friction: + +| Problem | Detail | +|---|---| +| Long-lived connection | Caller must maintain a socket, heartbeats, and reconnect logic for what is fundamentally a fire-and-collect workload. | +| Session identity | Inbound messages are keyed by the transient `connection_id` (`websocket_{connection_id}`); the caller **cannot supply a stable, business-meaningful session id** (e.g. a ticket number). Multi-ticket isolation is not expressible. | +| Auth mismatch | The debug socket is gated by the **dashboard JWT** (must not be handed to an external service); the embed socket is gated by **Cloudflare Turnstile** (a *browser* human-check that a backend cannot satisfy). Neither is a server-to-server credential. | +| In-memory, single-process state | Session history lives in process memory and is lost on restart. | + +> **Key realisation.** The N→1 / 1→M behaviour the caller wants is **not** +> provided by WebSocket — it is provided by the **pipeline** (aggregation + +> the adapter being free to call `reply_message` any number of times). It is +> therefore **transport-independent**. We can deliver the exact same semantics +> over a far lighter HTTP transport. + +### 1.3 Why a *new, standalone* adapter (not a refactor of an existing one) + +The brief is explicit: **do not reuse / fork an existing vendor adapter.** The +vendor adapters (`lark`, `wecom`, `qqofficial`, `slack`, …) carry vendor-specific +signature schemes, payload shapes, and message-segment mappings. Bending one of +them into a "generic" mode would couple a public integration surface to one +vendor's quirks and make the developer experience worse for everyone. + +Instead we ship `http_bot` as a clean, independent adapter whose **entire +contract is LangBot's own** — documented, versioned, and designed front-to-back +around *integrator* developer experience. + +--- + +## 2. Goals & Non-Goals + +### Goals + +- **G1** A standalone `http_bot` adapter, selectable like any other platform + adapter in the dashboard, with its own config schema and docs. +- **G2** **Inbound**: external systems POST messages to a stable LangBot URL, + carrying a **caller-defined `session_id`** that maps 1:1 to a LangBot session. +- **G3** **Outbound**: LangBot delivers each reply by POSTing to a + caller-configured **callback URL**; one turn may produce **many** callbacks. +- **G4** Preserve pipeline-native **N→1 aggregation** and **1→M multi-reply**. +- **G5** Server-to-server **auth**: shared-secret HMAC request signing both + directions (no JWT, no Turnstile, no long-lived socket). +- **G6** **Great DX**: copy-pasteable curl, a tiny reference client, an OpenAPI + fragment, idempotency, clear error envelope, and a local echo-server recipe. + +### Non-Goals + +- Not replacing or deprecating the WebSocket / embed widget path (that remains + the right tool for *browser*, real-time, streaming chat UIs). +- Not a synchronous "one request → one response" RPC (explicitly rejected: it + cannot express 1→M; see §9 for the optional sync convenience mode). +- No built-in message **persistence/replay** in v1 (callbacks are at-least-once + best-effort; durability is the caller's responsibility — see §8). +- No multi-tenant API-key management UI in v1 (one secret per bot; see §11). + +--- + +## 3. How LangBot routes a message (the parts we plug into) + +Understanding the existing flow is what makes this adapter cheap. A message +flows through these stages (verified against current `master`): + +``` + INBOUND OUTBOUND + external POST ─┐ ┌─ reply_message() + ▼ │ reply_message_chunk() + POST /bots/ (unified webhook router, AuthType.NONE) + │ webhooks.py → adapter.handle_unified_webhook(bot_uuid, path, request) + ▼ │ + HttpBotAdapter.handle_unified_webhook │ (called 0..N times + • verify HMAC signature │ per turn by the + • parse {session_id, message[]} │ pipeline / plugins) + • build FriendMessage / GroupMessage │ + • fire registered listener ───────────────┐ │ + │ │ │ + ▼ ▼ │ + botmgr.on_friend_message / on_group_message │ + • (optional) webhook_pusher fan-out │ + • msg_aggregator.add_message(...) ── N→1 debounce ──►│ + │ │ + ▼ │ + query_pool → pipeline.run() ─── invokes adapter ─────┘ + reply methods 1..M times +``` + +Two framework facts we rely on: + +1. **N→1 aggregation is free.** `botmgr` hands every inbound event to + `self.ap.msg_aggregator.add_message(...)`, which debounces per + `session_id` and merges consecutive messages into one pipeline turn + (`pkg/pipeline/aggregator.py`). The adapter does nothing special. + +2. **1→M is free.** The pipeline (and any plugin in the chain) calls + `adapter.reply_message()` / `reply_message_chunk()` **as many times as it + wants** per turn. The adapter's only job is to deliver each call outward. + For `http_bot` that means: **one outbound callback POST per call.** + +3. **A unified inbound route already exists.** `WebhookRouterGroup` + (`pkg/api/http/controller/groups/webhooks.py`) maps + `POST /bots/[/]` (auth `NONE`) to + `adapter.handle_unified_webhook(bot_uuid, path, request)`. `http_bot` + implements that method and is reachable **without registering any new + route** — it does its own signature verification, exactly like the vendor + webhook adapters do. + +> Net new code is essentially: one `http_bot.py` adapter, one `http_bot.yaml` +> schema, signing helpers, and docs. No router, aggregator, or pipeline changes. + +--- + +## 4. Architecture Overview + +``` +┌────────────────────┐ (1) inbound: POST signed message +│ External system │ ──────────────────────────────────────────────► ┌──────────────────────┐ +│ (LangBot Space, │ POST /bots/ │ LangBot │ +│ CRM, web app …) │ X-LB-Signature, X-LB-Timestamp │ │ +│ │ { session_id, message:[...] } │ HttpBotAdapter │ +│ - callback server │ ◄────────────────────────────────────────────── │ (platform/sources) │ +│ (receives │ (4) outbound: POST signed reply(s) │ │ +│ replies) │ POST │ pipeline + aggregator│ +└────────────────────┘ X-LB-Signature, X-LB-Timestamp └──────────────────────┘ + { session_id, sequence, is_final, + message:[...] } (sent 1..M times) +``` + +- The adapter is **stateless across requests** at the HTTP layer; session + continuity is carried by `session_id` and resolved by LangBot's normal + session manager. +- **Inbound** and **outbound** are **independent HTTP exchanges**. LangBot does + not answer the inbound POST with the pipeline result; it `202 Accepts` it and + later POSTs the reply(s) to the callback URL. This is what makes 1→M natural. + +--- + +## 5. Configuration Schema (`http_bot.yaml`) + +Follows the existing `MessagePlatformAdapter` manifest convention (cf. +`slack.yaml`). Fields: + +| field | type | required | purpose | +|---|---|---|---| +| `inbound_secret` | string (secret) | yes | HMAC key the **caller** uses to sign inbound POSTs; LangBot verifies. | +| `callback_url` | string (url) | no* | Where LangBot POSTs replies. *Optional if the caller supplies `callback_url` per-message (see §6.1); a static default lives here. | +| `outbound_secret` | string (secret) | no | HMAC key LangBot uses to sign outbound callbacks; caller verifies. Defaults to `inbound_secret` if empty. | +| `default_session_type` | enum `person`/`group` | no | Default when a message omits `session_type`. Default `person`. | +| `signature_required` | bool | no | If `false`, skip inbound signature check (dev only; logs a warning). Default `true`. | +| `callback_timeout` | int (seconds) | no | Per-callback HTTP timeout. Default `15`. | +| `callback_max_retries` | int | no | Retries on 5xx/timeout with backoff. Default `3`. | +| `webhook_url` | webhook-url (display) | — | Read-only field rendering the inbound URL `…/bots/` for copy-paste, like other webhook adapters. | + +Manifest sketch (i18n labels elided for brevity): + +```yaml +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: http_bot + label: { en_US: "HTTP Bot", zh_Hans: "HTTP 通用接入" } + description: + en_US: "Integrate any backend over plain HTTP. Push messages in, receive replies on a callback URL. Server-to-server, no long-lived connection." + zh_Hans: "通过 HTTP 接入任意后端系统。推入消息、在回调地址接收回复。面向服务间集成,无需长连接。" + icon: http_bot.svg +spec: + categories: [popular, global] + help_links: + zh: https://docs.langbot.app/zh/platforms/http-bot + en: https://docs.langbot.app/en/platforms/http-bot + config: + - { name: inbound_secret, type: string, required: true, default: "" } + - { name: callback_url, type: string, required: false, default: "" } + - { name: outbound_secret, type: string, required: false, default: "" } + - { name: default_session_type, type: select, required: false, default: "person", + options: [person, group] } + - { name: signature_required, type: boolean, required: false, default: true } + - { name: callback_timeout, type: integer, required: false, default: 15 } + - { name: callback_max_retries, type: integer, required: false, default: 3 } + - { name: webhook_url, type: webhook-url, required: false, default: "" } +execution: + python: + path: ./http_bot.py + attr: HttpBotAdapter +``` + +--- + +## 6. The HTTP Contract (this is the DX surface) + +### 6.1 Inbound — push a message into LangBot + +``` +POST /bots/{bot_uuid} +Content-Type: application/json +X-LB-Timestamp: 1718000000 +X-LB-Signature: sha256= +X-LB-Idempotency-Key: # optional, dedup window +``` + +Body: + +```jsonc +{ + "session_id": "ticket-10293", // REQUIRED. Caller-defined. Maps 1:1 to a LangBot session. + "session_type": "person", // optional, "person" | "group"; default from config + "sender": { // optional metadata, surfaced to pipeline/plugins + "id": "user-5567", + "name": "Alice" + }, + "message": [ // REQUIRED. A LangBot MessageChain (list of segments). + { "type": "Plain", "text": "Export keeps failing on the dashboard." }, + { "type": "Image", "url": "https://.../screenshot.png" } + ] +} +``` + +Response (LangBot does **not** block on the pipeline): + +```jsonc +// 202 Accepted +{ + "code": 0, + "msg": "accepted", + "data": { + "session_id": "ticket-10293", + "accepted_message_id": "in_01H....", // server-assigned id for this inbound message + "aggregating": true // true if buffered by the aggregator + } +} +``` + +**N→1 in practice.** Fire three POSTs with the same `session_id` inside the +aggregation window → the pipeline runs **once** with the three messages merged. +No special flag needed; this is the aggregator's default behaviour when enabled +on the pipeline. + +### 6.2 Outbound — LangBot delivers replies to your callback + +For each `reply_message` / `reply_message_chunk` the pipeline emits, LangBot +POSTs to `callback_url`: + +``` +POST {callback_url} +Content-Type: application/json +X-LB-Timestamp: 1718000001 +X-LB-Signature: sha256= +``` + +Body: + +```jsonc +{ + "session_id": "ticket-10293", // echoes the inbound session + "reply_to": "in_01H....", // the inbound message id this answers + "sequence": 1, // 1-based ordinal within this turn (for 1→M ordering) + "is_final": false, // false for intermediate/streamed parts + "stream": false, // true when this is a streamed chunk + "message": [ + { "type": "Plain", "text": "Looking into it — checking your export logs…" } + ], + "timestamp": "2026-06-22T09:00:01Z" +} +``` + +**1→M in practice.** A turn that fires a function call then a final answer +produces e.g.: + +``` +POST callback → { sequence: 1, is_final: false, message: ["Checking logs…"] } +POST callback → { sequence: 2, is_final: false, message: ["Found 2 failed exports."] } +POST callback → { sequence: 3, is_final: true, message: ["Fixed. Try again now."] } +``` + +The caller stitches by `session_id` + `sequence`, and knows the turn is complete +when `is_final: true` arrives. + +Your callback endpoint should return `200` quickly. A non-2xx triggers retry +with backoff (`callback_max_retries`). + +### 6.3 Error envelope (inbound) + +Consistent, machine-readable; never leak internals: + +```jsonc +{ "code": 40101, "msg": "invalid signature", "data": null } +``` + +| HTTP | code | meaning | +|---|---|---| +| 202 | 0 | accepted | +| 400 | 40001 | malformed body / missing `session_id` or `message` | +| 401 | 40101 | bad/expired signature | +| 403 | 40301 | bot disabled | +| 404 | 40401 | bot_uuid not found / not an `http_bot` adapter | +| 409 | 40901 | duplicate idempotency key (already accepted) | +| 413 | 41301 | message too large | +| 500 | 50001 | internal error | + +--- + +## 7. Signing scheme (both directions) + +Symmetric, dependency-free HMAC-SHA256 — trivial to implement in any language. + +``` +signing_string = "{timestamp}.{raw_request_body}" +signature = "sha256=" + hex(HMAC_SHA256(secret, signing_string)) +``` + +Verification rules: + +- Reject if `|now - timestamp| > 300s` (replay window). +- Constant-time compare (`hmac.compare_digest`). +- Inbound verified with `inbound_secret`; outbound signed with + `outbound_secret` (falls back to `inbound_secret`). +- `signature_required: false` bypasses verification **and logs a warning** — + intended only for local development behind a trusted network. + +Reference (Python, ~6 lines): + +```python +import hmac, hashlib, time + +def sign(secret: str, body: bytes, ts: int | None = None) -> tuple[str, str]: + ts = ts or int(time.time()) + mac = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256) + return str(ts), "sha256=" + mac.hexdigest() +``` + +--- + +## 8. Delivery semantics & reliability + +- **Inbound**: `202 Accepted` means *queued*, not *processed*. Use + `X-LB-Idempotency-Key` to make client retries safe (dedup window, e.g. 10 min). +- **Outbound**: **at-least-once**, best-effort. Retries on timeout/5xx with + exponential backoff up to `callback_max_retries`. Callbacks for one + `session_id` are delivered **in `sequence` order** (serialised per session); + across sessions they may interleave. +- **No persistence in v1**: if LangBot restarts mid-turn, in-flight callbacks + may be lost. Durable replay is deferred (see §13). Callers needing exactly-once + should dedup on `(session_id, reply_to, sequence)`. +- **Backpressure**: the adapter must not block the pipeline on slow callbacks — + outbound POSTs run on a per-session ordered queue with the configured timeout. + +--- + +## 9. Optional: synchronous convenience mode (v1.1, behind a flag) + +Some simple callers genuinely want "POST a message, get the reply in the HTTP +response" and don't care about streaming/multi-part. We can offer an **opt-in** +sync endpoint that internally waits for `is_final` and **collapses** all 1→M +parts into one array: + +``` +POST /bots/{bot_uuid}/sync → 200 { session_id, message: [ ...all parts concatenated... ] } +``` + +Implemented by attaching a per-request future that resolves on the final reply, +with a hard timeout. This is a **convenience wrapper** over the same machinery, +explicitly documented as lossy for streaming/ordering. Not in v1 core. + +--- + +## 10. Adapter implementation sketch (`platform/sources/http_bot.py`) + +Implements `AbstractMessagePlatformAdapter`. Key methods: + +```python +class HttpBotAdapter(AbstractMessagePlatformAdapter): + listeners: dict = pydantic.Field(default_factory=dict, exclude=True) + + # --- inbound ------------------------------------------------------- + async def handle_unified_webhook(self, bot_uuid, path, request): + body = await request.get_body() + if self.config.get("signature_required", True): + if not self._verify(request, body): + return jsonify({"code": 40101, "msg": "invalid signature"}), 401 + data = json.loads(body) + session_id = data["session_id"] # caller-defined identity + session_type = data.get("session_type", self.config.get("default_session_type", "person")) + chain = MessageChain.model_validate(data["message"]) + event = self._build_event(session_type, session_id, data.get("sender"), chain) + # remember where to send replies for this session + self._callback_for[session_id] = data.get("callback_url") or self.config.get("callback_url") + # fire the registered listener → botmgr → msg_aggregator (N→1) → pipeline + if type(event) in self.listeners: + asyncio.create_task(self.listeners[type(event)](event, self)) + return jsonify({"code": 0, "msg": "accepted", + "data": {"session_id": session_id, "accepted_message_id": event.message_id}}), 202 + + # --- outbound (called 1..M times per turn by the pipeline) --------- + async def reply_message(self, message_source, message, quote_origin=False): + return await self._post_callback(message_source, message, is_final=True, stream=False) + + async def reply_message_chunk(self, message_source, bot_message, message, + quote_origin=False, is_final=False): + return await self._post_callback(message_source, message, is_final=is_final, stream=True) + + async def is_stream_output_supported(self) -> bool: + return True + + def register_listener(self, event_type, func): self.listeners[event_type] = func + def unregister_listener(self, event_type, func): self.listeners.pop(event_type, None) + async def run_async(self): pass # nothing to poll; purely webhook-driven + async def kill(self): pass +``` + +`_post_callback` resolves the session's callback URL, assigns the next +`sequence`, signs the body, and enqueues an ordered, retrying POST. + +Session→callback mapping is kept in a small in-memory dict keyed by +`session_id` (acceptable for v1; a turn's callback URL is captured at inbound +time so replies always have a destination even if config later changes). + +--- + +## 11. Security considerations + +- **Inbound route is `AuthType.NONE`** at the framework level (same as all + webhook adapters) — the adapter **must** enforce HMAC itself. Default + `signature_required: true`. +- **Timestamp window** (±300s) + idempotency key blunt replay. +- **SSRF on callback_url**: validate scheme (`https` in prod), and consider an + allow-list / block of private CIDRs since LangBot initiates the POST. Document + this; enforce in code where feasible. +- **Secret storage**: secrets live in the bot's `adapter_config` like every + other adapter credential; surfaced as `type: string`/secret in the dashboard. +- **One secret per bot** in v1. Per-caller key rotation / multiple keys is a + future enhancement (§13). + +--- + +## 12. Developer Experience (explicit deliverables) + +The whole point of a standalone adapter is that **integrating is pleasant**. v1 +ships: + +1. **`docs/platforms/http-bot.md`** — task-oriented integration guide: + create the bot → copy inbound URL → set secret → stand up a callback + endpoint → send first message → handle 1→M. +2. **Copy-paste curl** for the first message (with a working signing one-liner). +3. **Reference clients** (≤50 LOC each) in `examples/http-bot/`: + `client.py` (push + a Flask/Quart callback receiver) and `client.ts`. +4. **OpenAPI fragment** `docs/http-bot-openapi.json` describing inbound + + callback shapes, so integrators can codegen. +5. **Local echo recipe**: a one-command callback server that prints every + reply, so a developer sees N→1 and 1→M working in under five minutes. +6. **Postman/Hoppscotch collection** (nice-to-have). + +DX acceptance check: *a developer who has never seen LangBot can, from the docs +alone, push a message and observe a multi-part reply on their callback within +10 minutes.* + +### Quickstart (curl) + +```bash +BOT=https://your-langbot/bots/2f1c.... +SECRET=supersecret +BODY='{"session_id":"ticket-10293","message":[{"type":"Plain","text":"hello"}]}' +TS=$(date +%s) +SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)" +curl -sS -X POST "$BOT" \ + -H "Content-Type: application/json" \ + -H "X-LB-Timestamp: $TS" \ + -H "X-LB-Signature: $SIG" \ + -d "$BODY" +``` + +--- + +## 13. Future work + +- **Durable outbound queue** (persist + replay across restarts; exactly-once). +- **Per-caller API keys** with rotation and scopes (multi-tenant Space usage). +- **Sync convenience endpoint** (§9) once core is stable. +- **Server-Sent Events outbound option** for callers that *do* want a stream but + not a full duplex socket — single GET, server pushes chunks. +- **Dashboard "test console"** for `http_bot` (send a message, watch callbacks) + mirroring the existing WebSocket debug panel. + +--- + +## 14. Rollout / task breakdown + +| # | Task | Touches | +|---|---|---| +| 1 | `http_bot.yaml` manifest + icon | `platform/sources/` | +| 2 | `HttpBotAdapter` (inbound verify, event build, outbound queue) | `platform/sources/http_bot.py` | +| 3 | Signing helper module (shared) | `platform/sources/` or `utils/` | +| 4 | i18n strings (en/zh/ja) | adapter yaml + web locale | +| 5 | Integration docs `docs/platforms/http-bot.md` | `docs/` | +| 6 | OpenAPI fragment + reference clients | `docs/`, `examples/http-bot/` | +| 7 | Tests: signature verify, N→1 aggregation, 1→M ordering, retry | `tests/` | +| 8 | (opt) SSRF guard for callback_url | adapter | + +No changes required to: the unified webhook router, the aggregator, the query +pool, or the pipeline. That is the design's main payoff. + +--- + +## 15. Resolved decisions + +1. **Callback URL trust** — **config-only.** The inbound message may not carry a + `callback_url`; replies always go to the bot-config URL. Closes the SSRF + vector where a leaked inbound secret could redirect replies. +2. **Session lifecycle** — **`POST /bots//reset`** (body `{session_id, + session_type?}`) drops the matching session from the session manager; the + next message starts a fresh conversation. Implemented via sub-path routing in + `handle_unified_webhook`. +3. **Group semantics** — for `session_type: group`, `session_id` is the group/ + launcher id; `sender.id` (and optional `sender.group_name`) identify the + member. A Space ticket maps to one `session_id`. +4. **Backpressure** — bounded per-session outbound queue (maxlen 1000); on + overflow the oldest reply is dropped and a warning logged, so a persistently + down callback can never exhaust memory. + +### Still open / deferred (see §13) + +- Durable outbound queue (persist + replay across restarts). +- Per-caller API keys with rotation/scopes for multi-tenant Space usage. +- SSE outbound option and a dashboard test console. diff --git a/docs/VALKEY_SEARCH_INTEGRATION.md b/docs/VALKEY_SEARCH_INTEGRATION.md new file mode 100644 index 000000000..aa95b0e70 --- /dev/null +++ b/docs/VALKEY_SEARCH_INTEGRATION.md @@ -0,0 +1,169 @@ +# Valkey Search Vector Database Integration + +This document describes how to use **Valkey Search** (the search/vector module bundled in +`valkey/valkey-bundle`) as the vector database backend for LangBot's knowledge base (RAG) +feature. + +## What is Valkey Search? + +**Valkey Search** is a module that adds vector similarity search and full-text search to +[Valkey](https://valkey.io/), the open-source, BSD-licensed in-memory data store forked from +Redis OSS. It is distributed in the `valkey/valkey-bundle` image alongside other modules +(JSON, Bloom, LDAP). + +LangBot talks to Valkey through the official [`valkey-glide`](https://pypi.org/project/valkey-glide/) +client (Rust core + async Python wrapper), using its native `ft` (search) command namespace. + +### Key Features + +- **Vector search**: ANN via HNSW or exact via FLAT, with COSINE / L2 / IP distance metrics +- **Full-text search**: term, prefix and phrase matching over indexed text fields +- **Hybrid search**: a metadata/text filter pre-selects candidates, then KNN ranks them +- **In-memory speed**: vectors and documents are stored as Valkey HASH keys +- **Auth + TLS**: optional username/password and TLS for production (toB / SaaS) deployments + +### Licensing + +- Valkey core and the Search module are **BSD-3-Clause**. +- The `valkey-glide` client is **Apache-2.0**. + +Both are compatible with LangBot. + +## Installation + +Valkey Search support is included when you install LangBot — the `valkey-glide` dependency is +declared in `pyproject.toml`. To install manually: + +```bash +pip install 'valkey-glide>=2.4.1,<3.0.0' +``` + +You also need a running Valkey server with the Search module loaded. The simplest way is the +bundled image: + +```bash +# Run valkey-bundle (includes the Search module) on host port 6380 +podman run -d --name valkey-test-langbot -p 6380:6379 valkey/valkey-bundle:9.1.0 +# (docker run ... works identically) +``` + +`valkey-bundle` ships multi-arch images (linux/amd64 + linux/arm64), so it runs on both CI +(x86_64) and Apple-silicon dev machines. + +## Configuration + +Valkey Search is **opt-in and disabled by default** — the default `vdb.use` stays `chroma`, +so existing single-process deployments are unaffected. To enable it, edit your `config.yaml`: + +```yaml +vdb: + use: valkey_search + valkey_search: + host: 'localhost' + port: 6379 # use 6380 if you started the container as shown above + db: 0 + password: '' # optional (ACL / requirepass) — never logged + username: '' # optional (ACL user) + tls: false # optional (toB / SaaS) + index_algorithm: 'HNSW' # HNSW | FLAT + distance_metric: 'COSINE' # COSINE | L2 | IP + request_timeout: 5000 # per-request timeout in ms +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `host` | `localhost` | Valkey host | +| `port` | `6379` | Valkey port | +| `db` | `0` | Logical database id | +| `password` | `''` | Optional auth password (empty = no auth). Never logged. | +| `username` | `''` | Optional ACL username. Configuring a username without a password fails closed (raises) rather than connecting unauthenticated. | +| `tls` | `false` | Enable TLS for the connection | +| `index_algorithm` | `HNSW` | `HNSW` (approximate) or `FLAT` (exact) | +| `distance_metric` | `COSINE` | `COSINE`, `L2`, or `IP` | +| `request_timeout` | `5000` | Per-request timeout in milliseconds. The valkey-glide default (250ms) is too low for vector KNN under load; raise it further for remote/cross-AZ Valkey. | + +### Connection behavior + +The backend uses a **lazy** connection (`lazy_connect=True`): the client is created on first +use and the connection is deferred to the first command. A misconfigured or unreachable Valkey +server therefore does **not** block LangBot from booting — knowledge-base operations will error +at call time instead, and you can recover by switching `vdb.use` back to another backend. + +The connection sets a fixed `client_name` of `langbot_vector_client` so it is identifiable in +`CLIENT LIST` and monitoring dashboards. + +## Supported search types + +| Type | Behavior | +|------|----------| +| `vector` | Pure KNN over the embedding field | +| `full_text` | Term/phrase match over the indexed `document` text field | +| `hybrid` | Metadata/text filter **pre-selects** candidates, then KNN ranks them | + +### ⚠️ Important: `vector_weight` is NOT honored + +Valkey Search hybrid queries follow a **filter-then-KNN** model: the filter (and/or full-text +clause) narrows the candidate set, and the KNN stage ranks the survivors by vector distance. +There is **no native weighted score fusion** (unlike, e.g., SeekDB's RRF boost). + +For interface compatibility the backend still accepts a `vector_weight` argument, but it is +**ignored** — passing different weights does not change result ordering. The first time a +non-default weight is supplied, the backend logs a one-time warning. + +If weighted hybrid ranking is needed in the future, it can be added **application-side** (run +vector KNN and full-text search separately and blend the scores). That is intentionally out of +scope for this integration. + +## Metadata & filtering + +Documents are stored as Valkey HASH keys under the prefix `kb:{collection}:{id}` with fields: + +- `vector` — the embedding, packed as little-endian FLOAT32 +- `document` — the raw text (indexed as TEXT for full-text/hybrid search) +- `file_id` — promoted to an indexed TAG field so it is filterable +- `metadata_json` — the full metadata dict, preserved verbatim as JSON + +Only **indexed** fields are filterable. Currently that is `file_id`. Filters referencing +non-indexed metadata keys are dropped with a warning (the same pragmatism used by the Milvus +and pgvector backends). All other metadata still round-trips intact via `metadata_json`. + +Supported filter operators (canonical Chroma-style `where` syntax): `$eq`, `$ne`, `$gt`, +`$gte`, `$lt`, `$lte`, `$in`, `$nin`. Multiple top-level keys are AND-ed. + +## Testing + +Unit tests (filter mapping, float32 packing, reply parsing, import guard) run in the fast lane +with no server: + +```bash +uv run pytest tests/unit_tests/vector/test_valkey_search_filter.py -q +``` + +Integration tests are **slow-gated** on `TEST_VALKEY_URL` and require a running server: + +```bash +podman run -d --name valkey-test-langbot -p 6380:6379 valkey/valkey-bundle:9.1.0 +TEST_VALKEY_URL=valkey://localhost:6380 \ + uv run pytest tests/integration/vector/test_valkey_search.py -m slow -q +``` + +The default upstream fast CI lane (`-m "not slow"`) skips these, matching the existing +PostgreSQL migration-test precedent. + +## Troubleshooting + +| Symptom | Cause / fix | +|---------|-------------| +| Tests skip with "Valkey Search module not available" | The server is plain Valkey without the Search module. Use the `valkey/valkey-bundle` image. | +| `ConnectionError` at call time | Check `host`/`port`/auth; remember `lazy_connect` defers errors to first use. | +| Empty search results right after insert | The Search indexer is asynchronous; results become visible within a short delay. The integration tests poll/retry to account for this. | +| Hybrid ranking ignores `vector_weight` | Expected — see the caveat above. | + +## Production considerations + +- **Cluster mode**: Valkey Search in cluster mode uses an additional coordination port. This + integration targets standalone mode; cluster support is a future consideration. +- **Persistence**: configure Valkey RDB/AOF persistence if the knowledge base must survive + restarts; otherwise an in-memory store is ephemeral. +- **Security**: set `password`/`username` and `tls: true` for any non-local deployment. + Credentials are never written to logs. diff --git a/docs/http-bot-openapi.json b/docs/http-bot-openapi.json new file mode 100644 index 000000000..2cac6e439 --- /dev/null +++ b/docs/http-bot-openapi.json @@ -0,0 +1,198 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "LangBot HTTP Bot Adapter", + "version": "1.0.0", + "description": "Server-to-server HTTP integration for a LangBot pipeline. Inbound messages are POSTed to the unified webhook route; replies are delivered to a configured callback URL (one POST per reply part). All requests are HMAC-SHA256 signed. See docs/platforms/http-bot.md." + }, + "paths": { + "/bots/{bot_uuid}": { + "post": { + "summary": "Push a message into the pipeline (fire-and-collect)", + "description": "Returns 202 immediately. Replies arrive asynchronously on the configured callback URL. Reuse the same session_id within the aggregation window to merge multiple messages into one turn (N->1).", + "parameters": [ + { "$ref": "#/components/parameters/BotUuid" }, + { "$ref": "#/components/parameters/Timestamp" }, + { "$ref": "#/components/parameters/Signature" }, + { "$ref": "#/components/parameters/Idempotency" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InboundMessage" } } } + }, + "responses": { + "202": { + "description": "Accepted (queued for the pipeline)", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcceptedResponse" } } } + }, + "400": { "$ref": "#/components/responses/Error" }, + "401": { "$ref": "#/components/responses/Error" }, + "409": { "$ref": "#/components/responses/Error" }, + "413": { "$ref": "#/components/responses/Error" } + } + } + }, + "/bots/{bot_uuid}/sync": { + "post": { + "summary": "Push a message and wait for the collapsed reply", + "description": "Blocking convenience mode. Waits for is_final and returns all reply parts collapsed into one array. Lossy (no sequence/streaming). One in-flight sync per session_id.", + "parameters": [ + { "$ref": "#/components/parameters/BotUuid" }, + { "$ref": "#/components/parameters/Timestamp" }, + { "$ref": "#/components/parameters/Signature" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InboundMessage" } } } + }, + "responses": { + "200": { + "description": "The collapsed reply", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SyncResponse" } } } + }, + "400": { "$ref": "#/components/responses/Error" }, + "401": { "$ref": "#/components/responses/Error" } + } + } + }, + "/bots/{bot_uuid}/reset": { + "post": { + "summary": "Reset a session's conversation", + "parameters": [ + { "$ref": "#/components/parameters/BotUuid" }, + { "$ref": "#/components/parameters/Timestamp" }, + { "$ref": "#/components/parameters/Signature" } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["session_id"], + "properties": { + "session_id": { "type": "string" }, + "session_type": { "type": "string", "enum": ["person", "group"] } + } + } + } + } + }, + "responses": { + "200": { "description": "Reset done" }, + "400": { "$ref": "#/components/responses/Error" }, + "401": { "$ref": "#/components/responses/Error" } + } + } + } + }, + "components": { + "parameters": { + "BotUuid": { + "name": "bot_uuid", "in": "path", "required": true, + "schema": { "type": "string", "format": "uuid" } + }, + "Timestamp": { + "name": "X-LB-Timestamp", "in": "header", "required": true, + "description": "Unix seconds; rejected if more than +/-300s from server time.", + "schema": { "type": "string" } + }, + "Signature": { + "name": "X-LB-Signature", "in": "header", "required": true, + "description": "sha256= of HMAC-SHA256(secret, \"{timestamp}.\" + raw_body).", + "schema": { "type": "string" } + }, + "Idempotency": { + "name": "X-LB-Idempotency-Key", "in": "header", "required": false, + "description": "Dedup key; a repeat within the dedup window returns 409.", + "schema": { "type": "string" } + } + }, + "schemas": { + "Segment": { + "type": "object", + "required": ["type"], + "properties": { + "type": { "type": "string", "enum": ["Plain", "Image", "Voice", "File", "At", "Quote"] }, + "text": { "type": "string", "description": "For type=Plain." }, + "url": { "type": "string", "description": "For media types." }, + "base64": { "type": "string", "description": "For media types (data URI or raw base64)." } + } + }, + "InboundMessage": { + "type": "object", + "required": ["session_id", "message"], + "properties": { + "session_id": { "type": "string", "description": "Caller-defined; maps 1:1 to a LangBot session." }, + "session_type": { "type": "string", "enum": ["person", "group"], "default": "person" }, + "sender": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "group_name": { "type": "string", "description": "For session_type=group." } + } + }, + "message": { "type": "array", "items": { "$ref": "#/components/schemas/Segment" } } + } + }, + "AcceptedResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", "example": 0 }, + "msg": { "type": "string", "example": "accepted" }, + "data": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "accepted_message_id": { "type": "string", "example": "in_01H..." }, + "aggregating": { "type": "boolean" } + } + } + } + }, + "SyncResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", "example": 0 }, + "msg": { "type": "string", "example": "ok" }, + "data": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "reply_to": { "type": "string" }, + "message": { "type": "array", "items": { "$ref": "#/components/schemas/Segment" } } + } + } + } + }, + "Callback": { + "type": "object", + "description": "Delivered by LangBot to your callback_url, one POST per reply part. Signed with the outbound secret.", + "properties": { + "session_id": { "type": "string" }, + "reply_to": { "type": "string", "description": "The accepted_message_id this answers." }, + "sequence": { "type": "integer", "description": "1-based ordinal within the turn." }, + "is_final": { "type": "boolean", "description": "True on the last part of the turn." }, + "stream": { "type": "boolean" }, + "message": { "type": "array", "items": { "$ref": "#/components/schemas/Segment" } }, + "timestamp": { "type": "string", "format": "date-time" } + } + }, + "ErrorEnvelope": { + "type": "object", + "properties": { + "code": { "type": "integer", "example": 40101 }, + "msg": { "type": "string", "example": "invalid signature: signature_mismatch" }, + "data": { "nullable": true } + } + } + }, + "responses": { + "Error": { + "description": "Error envelope", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } + } + } + } +} diff --git a/docs/platforms/http-bot.md b/docs/platforms/http-bot.md new file mode 100644 index 000000000..71fbc6799 --- /dev/null +++ b/docs/platforms/http-bot.md @@ -0,0 +1,256 @@ +# HTTP Bot Adapter — Integration Guide + +Integrate **any backend system** with a LangBot pipeline over plain HTTP. Push +messages in via a signed webhook; receive replies on a callback URL. No +long-lived connection, full support for message **aggregation** (many inbound +messages merged into one turn) and **multi-part replies** (one turn → many +outbound messages). + +This is the right adapter for **server-to-server** integrations — ticketing +systems, CRMs, internal tools, custom web backends. (For an in-browser, +real-time chat widget, use the embeddable Web Page Bot instead.) + +> **5-minute goal:** stand up a callback receiver, send a message, and watch a +> multi-part reply arrive — using the reference client in +> [`examples/http-bot/`](../../examples/http-bot/). + +--- + +## 1. Mental model + +``` +Your backend ──(1) POST signed message──► LangBot /bots/ + (pipeline runs: aggregate → think → reply) +Your callback ◄─(2) POST signed reply(s)── LangBot one POST per reply part +``` + +- **(1) Inbound** is *fire-and-collect*: LangBot answers `202 Accepted` + immediately and does **not** return the pipeline result on that response. +- **(2) Outbound** replies arrive later as separate signed POSTs to your + `callback_url`. A single turn may produce **several** callbacks (e.g. a tool + call narration followed by the final answer). +- Everything is keyed by a **`session_id` you choose** (e.g. a ticket number). + Each `session_id` maps to one isolated LangBot conversation. + +--- + +## 2. Create the bot + +1. In the LangBot dashboard, add a bot and choose the **HTTP Bot** platform. +2. Fill in the config: + + | Field | Required | Notes | + |---|---|---| + | **Inbound Signing Secret** | yes | Your backend signs inbound requests with this. | + | **Outbound Callback URL** | yes | Where LangBot POSTs replies. **Config-only** — cannot be overridden per message (SSRF protection). | + | **Outbound Signing Secret** | no | LangBot signs callbacks with this; defaults to the inbound secret. | + | **Default Session Type** | no | `person` (default) or `group`. | + | **Require Inbound Signature** | no | Keep `true` in production. | + | **Callback Timeout / Max Retries** | no | Defaults: 15s, 3 retries. | + +3. Bind the bot to a **pipeline** and **enable** it. +4. Copy the **Inbound Webhook URL** shown in the config — it looks like + `https://your-langbot/bots/`. + +--- + +## 3. The signature scheme + +Both directions use the same dependency-free HMAC-SHA256 scheme: + +``` +signing_string = "{timestamp}." + raw_body_bytes +signature = "sha256=" + hex(HMAC_SHA256(secret, signing_string)) +``` + +Sent as headers: + +| Header | Meaning | +|---|---| +| `X-LB-Timestamp` | Unix seconds. Rejected if more than **±300s** from server time. | +| `X-LB-Signature` | `sha256=` over `"{timestamp}." + body`. | +| `X-LB-Idempotency-Key` | *(optional, inbound)* dedup key; retries with the same key return `409`. | + +Verify outbound callbacks the same way, using the **outbound** secret (or the +inbound secret if you left it blank). + +A six-line reference implementation is in `examples/http-bot/client.py` +(`sign()` / `verify()`); a Node/TS version is in `client.ts`. + +--- + +## 4. Send your first message (curl) + +```bash +BOT="https://your-langbot/bots/" +SECRET="your-inbound-secret" +BODY='{"session_id":"ticket-10293","message":[{"type":"Plain","text":"Export keeps failing on the dashboard."}]}' +TS=$(date +%s) +SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)" + +curl -sS -X POST "$BOT" \ + -H "Content-Type: application/json" \ + -H "X-LB-Timestamp: $TS" \ + -H "X-LB-Signature: $SIG" \ + -d "$BODY" +# -> 202 {"code":0,"msg":"accepted","data":{"session_id":"ticket-10293","accepted_message_id":"in_...","aggregating":true}} +``` + +The reply(s) will be POSTed to your configured callback URL shortly after. + +--- + +## 5. Inbound request format + +`POST /bots/{bot_uuid}` + +```jsonc +{ + "session_id": "ticket-10293", // REQUIRED. Your stable id. Maps 1:1 to a LangBot session. + "session_type": "person", // optional: "person" | "group"; default from config + "sender": { // optional metadata, surfaced to the pipeline/plugins + "id": "user-5567", + "name": "Alice" + }, + "message": [ // REQUIRED. A LangBot MessageChain (array of segments). + { "type": "Plain", "text": "Export keeps failing on the dashboard." }, + { "type": "Image", "url": "https://example.com/screenshot.png" } + ] +} +``` + +**Message segments.** Text uses `{"type":"Plain","text":"..."}`. Images use +`{"type":"Image","url":"..."}` (or `base64`). Other supported types: `Voice`, +`File`, `At`, `Quote`. + +> Note: the callback URL is **not** accepted in the body — it is taken only from +> bot config. This is deliberate (prevents an attacker who obtains the inbound +> secret from redirecting replies to an arbitrary host). + +### Aggregation (N → 1) + +If your pipeline has **message aggregation** enabled, send several messages with +the **same `session_id`** within the aggregation window and they are merged into +**one** pipeline turn. No special flag — just reuse the `session_id`. + +--- + +## 6. Outbound callback format + +LangBot POSTs each reply part to your `callback_url`: + +```jsonc +{ + "session_id": "ticket-10293", // echoes the inbound session + "reply_to": "in_01H...", // the accepted_message_id this answers + "sequence": 1, // 1-based ordinal within this turn + "is_final": false, // true on the last part of the turn + "stream": false, // true for streamed chunks + "message": [ { "type": "Plain", "text": "Looking into it…" } ], + "timestamp": "2026-06-22T09:00:01Z" +} +``` + +Your endpoint should return `2xx` quickly. Non-2xx / timeout → LangBot retries +with exponential backoff (up to `callback_max_retries`). + +### Multi-part replies (1 → M) + +One turn may emit multiple callbacks, delivered **in `sequence` order** for a +given session: + +``` +seq=1 is_final=false "Checking your export logs…" +seq=2 is_final=false "Found 2 failed exports." +seq=3 is_final=true "Fixed — please try again." +``` + +Stitch by `session_id` + `sequence`; the turn is complete when +`is_final: true` arrives. + +--- + +## 7. Reset a session + +Start a fresh conversation for a `session_id` (drops history): + +``` +POST /bots/{bot_uuid}/reset +{ "session_id": "ticket-10293", "session_type": "person" } +→ 200 { "code":0, "msg":"reset", "data": { "session_id":"ticket-10293", "removed": true } } +``` + +Signed exactly like an inbound message. + +--- + +## 8. Synchronous convenience mode + +If you don't need streaming/multi-part and just want one reply back on the same +HTTP call, POST to `/sync`. LangBot waits for the turn to finish and returns all +parts **collapsed** into one array: + +``` +POST /bots/{bot_uuid}/sync +{ "session_id": "ticket-10293", "message": [ { "type":"Plain", "text":"hi" } ] } +→ 200 { "code":0, "msg":"ok", + "data": { "session_id":"ticket-10293", "reply_to":"in_...", + "message": [ {"type":"Plain","text":"..."}, ... ] } } +``` + +This is **lossy** (you lose `sequence` / streaming boundaries) and blocks up to +`callback_timeout × 4` seconds. Prefer the callback model for anything +real-time or multi-part. Only one in-flight `/sync` per `session_id`. + +--- + +## 9. Error envelope + +```jsonc +{ "code": 40101, "msg": "invalid signature: signature_mismatch", "data": null } +``` + +| HTTP | code | meaning | +|---|---|---| +| 202 | 0 | accepted | +| 400 | 40001 | malformed body / missing `session_id` or `message` | +| 401 | 40101 | bad/expired signature | +| 409 | 40901 | duplicate idempotency key | +| 413 | 41301 | message too large (>1 MiB) | +| 500 | 50001 | internal error | + +--- + +## 10. Try it end-to-end in 5 minutes + +```bash +cd examples/http-bot +pip install flask requests + +# Terminal 1 — your callback receiver (point the bot's callback_url here, e.g. via a tunnel): +python client.py serve --port 8900 --secret SHARED_SECRET + +# Terminal 2 — push a message: +python client.py push \ + --url https://your-langbot/bots/ \ + --secret SHARED_SECRET \ + --session ticket-1 \ + --text "hello" +``` + +Watch Terminal 1 print each reply part (`[part ]` / `[FINAL]`) with its +sequence number — that's 1→M working, signatures verified. + +A machine-readable contract is in +[`docs/http-bot-openapi.json`](../http-bot-openapi.json). + +--- + +## 11. Security checklist + +- Keep **Require Inbound Signature** on in production. +- Use **HTTPS** callback URLs; the URL is config-only (no per-message override). +- Treat the secrets like passwords; rotate via the dashboard. +- The inbound route is unauthenticated at the framework level **by design** — + security comes entirely from the HMAC signature, so never disable it on a + public deployment. diff --git a/docs/review/box-architecture.md b/docs/review/box-architecture.md new file mode 100644 index 000000000..2a5e06e65 --- /dev/null +++ b/docs/review/box-architecture.md @@ -0,0 +1,595 @@ +# Box 系统架构深度分析 + +> 更新日期: 2026-06-02 +> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。 +> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) +> 相关文档: [SaaS 阻塞项](./box-issues.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md) + +--- + +## 1. 全局架构 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ LangBot 主进程 │ +│ │ +│ LocalAgentRunner ──> ToolManager ──> NativeToolLoader │ +│ │ │ │ │ +│ │ │ exec / read / write / edit │ +│ │ │ glob / grep │ +│ │ │ │ +│ │ ├──> MCPLoader ──> BoxStdioSession │ +│ │ │ (shared 容器, 多 process) │ +│ │ │ │ +│ │ ├──> SkillToolLoader (activate 工具) │ +│ │ │ │ +│ │ ├──> SkillAuthoringToolLoader │ +│ │ │ │ +│ │ └──> PluginToolLoader │ +│ │ │ +│ BoxService (门面) │ +│ ├─ Profile 管理 (locked 字段) │ +│ ├─ Host mount 校验 (allowed_mount_roots) │ +│ ├─ Workspace quota 检查 │ +│ ├─ 输出截断 (head+tail) │ +│ ├─ Session ID 模板解析 (resolve_box_session_id) │ +│ ├─ 技能挂载组装 (build_skill_extra_mounts) │ +│ ├─ 重连循环 (_reconnect_loop, 指数退避) │ +│ └─ BoxRuntimeConnector │ +│ ├─ 心跳 loop (20s ping) │ +│ └─ ActionRPCBoxClient │ +│ │ Action RPC (stdio 或 WebSocket) │ +│ │ +│ SkillManager (skill_mgr) │ +│ └─ 从 Box runtime 拉取 skills, 不可用时回落 data/skills │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Box Runtime 进程 (SDK 侧) │ +│ │ +│ BoxServerHandler (Action RPC 处理, INIT 配置注入) │ +│ │ │ +│ BoxRuntime (session 管理 / 进程生命周期 / TTL reaper) │ +│ │ └─ session.managed_processes: dict[pid, _ManagedProcess] +│ │ │ +│ Backend (启动时根据 box.backend 配置选择): │ +│ DockerBackend ──┐ │ +│ PodmanBackend ──┤── CLISandboxBackend │ +│ NsjailBackend ──┘ (本地 CLI 或 fallback 到容器内 CLI) │ +│ E2BBackend (云沙箱, 需要 E2B_API_KEY) │ +│ │ +│ BoxSkillStore │ +│ ├─ list / get / create / update / delete │ +│ ├─ scan_skill_directory / read_skill_file / write_skill_file │ +│ └─ preview_skill_zip / install_skill_zip (zip 或 GitHub) │ +│ │ +│ aiohttp 单端口服务 (默认 :5410): │ +│ /rpc/ws — Action RPC │ +│ /v1/sessions/{id}/managed-process/ws — 默认 process │ +│ /v1/sessions/{id}/managed-process/{pid}/ws — 指定 process │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ 容器 / 沙箱 (Docker/Podman 容器, nsjail sandbox, 或 E2B 远程沙箱) │ +│ - 隔离文件系统 / 网络 / PID 命名空间 │ +│ - 资源限制 (CPU, 内存, PID 数, 可选 workspace 配额) │ +│ - 主挂载 (host_path → mount_path) + 任意条 extra_mounts │ +│ └─ Skills 通过 extra_mounts 挂在 /workspace/.skills/ │ +│ - exec: 用户命令在此执行 │ +│ - managed process: 多个长驻进程并存 (MCP Server / 自定义服务) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +**核心设计原则**: +- Box Runtime 作为独立进程运行,通过 Action RPC 与 LangBot 主进程通信,两者复用 SDK 的 IO 层(Handler → Connection → Controller) +- 一个 session_id 对应一个容器/沙箱实例。同一 session 内可并存多条 mount 与多个 managed process +- Skill / 默认 exec / MCP Server 共享同一个 session 容器(详见 [box-session-scope.md](./box-session-scope.md)) + +--- + +## 2. LangBot 侧模块 + +### 2.1 BoxService (`pkg/box/service.py`, 722 行) + +应用层门面,协调 Profile、安全校验、配额、连接、Skill 挂载与 Session 模板: + +主要公开方法(按定义顺序): + +``` +BoxService + ├─ initialize() 连接 Box Runtime + 默认 workspace 准备 + ├─ _on_runtime_disconnect(connector) 触发重连 + ├─ _reconnect_loop(connector) 指数退避重连 + ├─ available (property) 连接状态 + │ + ├─ resolve_box_session_id(query) 从 pipeline 模板解析 session_id + ├─ build_skill_extra_mounts(query) 组装 pipeline-bound skill 的挂载列表 + │ + ├─ execute_tool(parameters, query) Agent 调用 exec 时的入口 + │ ├─ _apply_profile / build_spec + │ ├─ _validate_host_mount + │ ├─ _enforce_workspace_quota (phase=pre) + │ ├─ client.execute(spec) + │ ├─ _enforce_workspace_quota (phase=post) + │ └─ _truncate (stdout/stderr) + │ + ├─ execute_spec_payload(spec_payload, ...) 内部入口(其他 loader 调用) + ├─ create_session(spec_payload, ...) 显式创建 session + ├─ start_managed_process(session_id, ...) 启动 managed process + ├─ get_managed_process(session_id, pid) 查询进程状态(pid 默认 'default') + ├─ stop_managed_process(session_id, pid) 单独停止某个 managed process + ├─ get_managed_process_websocket_url(...) 返回 WS attach URL + │ + ├─ list_skills() / get_skill(name) Skill 元数据 + ├─ create_skill / update_skill / delete_skill Skill CRUD + ├─ scan_skill_directory(path) 扫描目录 + ├─ list_skill_files / read_skill_file / write_skill_file + ├─ preview_skill_zip / install_skill_zip zip / GitHub 安装 + │ + ├─ shutdown() / dispose() 清理:RPC SHUTDOWN + 进程终止 + ├─ get_status() / get_sessions() / get_recent_errors() + └─ get_system_guidance() LLM 系统提示 +``` + +**Profile 系统**: 4 个内置 Profile(`default` / `offline_readonly` / `network_basic` / `network_extended`),`locked` frozenset 字段不可被 LLM 覆盖。参数合并顺序:Profile defaults → LLM 请求参数 → locked 强制值。 + +**输出截断**: 默认 4000 字符上限,保留前 60% + 后 40%,中间插入 `[...truncated...]`。 + +**Skill 挂载合并**: `execute_tool()` 调用时,`build_skill_extra_mounts(query)` 会把当前 pipeline-bound 的所有 skill 的 `package_root` 作为 `extra_mounts` 加入 BoxSpec,挂在 `/workspace/.skills/`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径。 + +### 2.2 BoxRuntimeConnector (`pkg/box/connector.py`, 357 行) + +管理与 Box Runtime 的通信连接: + +- **本地 stdio**: Unix/macOS 默认路径,fork `python -m langbot_plugin.cli.__init__ box -s --ws-control-port {port}` 子进程(与 plugin runtime 统一走 `lbp` CLI 入口) +- **本地 subprocess + WS**: Windows 本地(asyncio ProactorEventLoop 不支持 stdio pipe) +- **远程 WebSocket**: Docker 部署 / `box.runtime.endpoint` 显式配置时,连接 `ws://{host}:{port}/rpc/ws` +- **同步等待**: `asyncio.Event` + `wait_for(timeout=30s)` 模式确认连接 +- **心跳**: `_heartbeat_loop()` 每 20s 调用 `ping()`,失败仅 DEBUG 日志(断开检测靠 connection close) +- **重连**: `runtime_disconnect_callback` 由 BoxService 提供,触发 `_reconnect_loop` +- **INIT 注入**: 连接建立后立即下发当前 `box.*` 配置子树(剔除 `runtime` 私有字段),Runtime 据此初始化 backend + +> **历史改进**: 2026-04-16 版本本文档曾列 P0 「Box 无心跳 / 无重连」,已修复(commit `2dfd9d5d`、`c6882cf`、`5029d9c` 等)。 + +### 2.3 BoxWorkspaceSession 工具 (`pkg/box/workspace.py`, 413 行) + +此文件目前提供两类能力: + +1. **路径与命令重写工具函数** — `normalize_host_path` / `rewrite_mounted_path` / `unwrap_venv_path` / `rewrite_venv_command` / `infer_workspace_host_path`,被 MCP loader 与 Skill 路径解析共用。 +2. **`BoxWorkspaceSession`** — 围绕 BoxService 的轻量包装,专供 MCP-in-Box 场景使用(管理一个共享 session 的 session_id、构建挂载 payload、stage host 文件到共享 workspace)。 + +**变化点**: 早期 Skill exec 会为每个 skill 创建独立 BoxWorkspaceSession(独占 session);当前实现已转为 `extra_mounts` 模式,Skill 不再独占容器,只追加挂载。这部分 wrapping 逻辑已从 native loader 移除。 + +### 2.4 policy.py (`pkg/box/policy.py`, 98 行) — 仍是死代码 + +三层安全策略设计(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy`),全项目无任何导入或调用。详见 [SaaS 阻塞项 S2](./box-issues.md)。 + +### 2.5 SkillManager (`pkg/skill/manager.py`, 186 行) + +``` +SkillManager + ├─ initialize() 调用 reload_skills() + ├─ reload_skills() 先从 Box runtime list_skills(), + │ 不可用则回落 data/skills/ 扫描 + ├─ refresh_skill_from_disk() 单 skill 重新加载 + ├─ get_skill_by_name(name) + └─ get_managed_skills_root() 返回 Box 视角的 skills_root 路径 +``` + +skill 元数据通过 `parse_frontmatter` 解析 `SKILL.md` 头部(`name` / `description` / `instructions`),不再做整体扫描的代价(典型 < 50 个)。 + +### 2.6 Skill activation (`pkg/skill/activation.py`, 33 行) + Skill loader 辅助 + +历史上 skill 通过 LLM 在文本中输出 `[ACTIVATE_SKILL:name]` 标记激活;当前已改为 **Tool Call 机制**: + +- `SkillToolLoader` (`pkg/provider/tools/loaders/skill.py`, 157 行) 暴露 `activate` 工具,参数为 skill 名 +- 工具实现调用 `register_activated_skill(query, skill_data)`,将激活态写入 `query.variables['_activated_skills']` +- 这种 KV-cache-friendly 模式对齐 Claude Code 设计;详见 [box-session-scope.md §4.3](./box-session-scope.md) 的 Tool Call 描述 + +`activation.py` 现仅保留对外辅助函数(pipeline 层调用 loader 的 `register_activated_skill`)。 + +--- + +## 3. SDK 侧模块 + +### 3.1 BoxRuntime (`box/runtime.py`, 599 行) + +核心编排器,管理 session 生命周期与 backend 调度: + +``` +Session 生命周期: + + Client EXEC / CREATE_SESSION + │ + ▼ + _get_or_create_session(spec) + ├─ _reap_expired_sessions_locked() 清理 TTL 过期 session + ├─ 已存在? → _assert_session_compatible() → 复用 + ├─ Backend session 失踪? → 重建 (commit c6882cf) + └─ 新建? → backend.start_session(spec) → 创建容器 + │ └─ 应用 spec.extra_mounts (多挂载) + ▼ + execute(spec) + ├─ 获取 session lock (每 session 独立) + ├─ backend.exec(session, spec) 在容器中执行命令 + ├─ 更新 last_used_at + └─ 超时? → 销毁 session + │ + ▼ + Session 保持存活直到: + ├─ TTL 过期 (默认 300s,下次操作时清理) + ├─ 执行超时 (自动销毁) + ├─ 客户端 DELETE_SESSION + └─ SHUTDOWN +``` + +**关键设计**: +- 每 session 有独立 `asyncio.Lock`,同一 session 内的命令串行执行 +- 每 session 维护 `managed_processes: dict[process_id, _ManagedProcess]`,支持多个长驻进程并存(MCP / 自定义) +- 全局 `_lock` 保护 `_sessions` dict 的读写 +- 兼容性检查:比较核心 spec 字段,`image` 字段对不支持自定义镜像的 backend(nsjail/E2B)会跳过 + +**Backend 选择 (`_select_backend`)**: 优先级 +1. 显式 `box.backend` 配置(`docker` / `nsjail` / `e2b`) +2. `local` (默认) → Docker / Podman / nsjail CLI 顺序探测 +3. `get_status` 调用时若当前 backend 不可用,会尝试重新选择 (commit `e5617c7`) + +### 3.2 Backend 系统 + +#### CLISandboxBackend (`box/backend.py`, 411 行) + +Docker / Podman 公共基类: + +``` +start_session(spec): + 1. validate_sandbox_security(spec) + 2. docker/podman run -d --rm --name + --network none (可选) + --cpus/--memory/--pids-limit + --read-only + --tmpfs /tmp + -v :: 主挂载 + -v ::.. 额外挂载 (extra_mounts) + sh -lc 'while true; do sleep 3600; done' + 3. 返回 BoxSessionInfo + +exec(session, spec): + docker/podman exec -e KEY=VAL + sh -lc 'mkdir -p && cd && ' + +start_managed_process(session, spec): + docker/podman exec -i + sh -lc 'mkdir -p && cd && exec ' + 返回 asyncio.subprocess.Process (stdin/stdout PIPE) +``` + +容器以 idle 进程启动,实际命令通过 `docker exec` 执行。`--rm` 确保容器退出时自动清理。 + +**Windows 支持**: backend 内对 Windows 路径处理与 subprocess 调用做了适配(commit `120817a`)。 + +**孤儿清理**: 启动时枚举 `langbot.box=true` 标签的容器,instance_id 不匹配的强制删除。 + +#### NsjailBackend (`box/nsjail_backend.py`, 552 行) + +轻量级 Linux 沙箱(无容器引擎依赖): + +- 使用 namespace 隔离(user/mount/pid/ipc/uts/cgroup/net) +- 挂载宿主 `/usr`/`/lib`/`/bin`/`/sbin` 只读 + 选定 `/etc` 条目 +- 每 session 创建独立目录(workspace/tmp/home) +- 资源限制: cgroup v2 优先,fallback 到 rlimit +- **CLI 兼容**: 通过 `shutil.which(self._nsjail_bin)` 检测系统安装版 nsjail;不存在时再尝试容器内 nsjail(commit `686fcc0`、`feed530`) +- **无自定义镜像**: 使用宿主 OS,`image` 字段固定为 `'host'`,兼容性检查跳过 image + +#### E2BBackend (`box/e2b_backend.py`, 429 行) + +云沙箱后端(commit `75b547f` 引入): + +- 通过 `e2b` SDK 与 E2B 平台通信 +- 配置:`box.e2b.api_key` / `api_url` / `template` +- 支持 `extra_mounts`(commit `0fea9b1` 同步上传文件) +- 无本地容器引擎依赖,适合无 Docker 的部署或 SaaS 多租户场景 +- 不支持自定义 image 字段,由 template 控制 + +### 3.3 Server (`box/server.py`, 508 行) + +单端口 aiohttp 服务(默认 5410),通过路径区分(commit `8c71ec5` 合并端口): + +1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理所有 action,包括 `INIT` 配置注入、skill store 操作等 +2. **WS Relay** (`/v1/sessions/{id}/managed-process/ws` 与 `/v1/sessions/{id}/managed-process/{pid}/ws`): 双向桥接 WebSocket ↔ 指定 managed process stdin/stdout + +stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process attach;Action RPC 走 stdin/stdout。 + +### 3.4 Client (`box/client.py`, 377 行) + +`ActionRPCBoxClient` 封装 `Handler.call_action()` 调用: + +- 25+ 方法对应 25+ 个 RPC action(exec / session / managed-process / skill / status / shutdown) +- 错误还原: `_translate_action_error()` 通过字符串前缀匹配还原 SDK 侧异常类型 +- `execute()` timeout = 300s,其他默认 15s +- `BoxRuntimeClient` 是 ABC,供后续可能的非 RPC 实现复用 + +包级别 `__init__.py` 显式导出:`BoxRuntimeClient`、`ActionRPCBoxClient`(commit `df9c722`)。 + +### 3.5 Actions (`box/actions.py`, 34 行) + +`LangBotToBoxAction` 枚举共定义 **25 个** action: + +| 类别 | Actions | +|------|---------| +| 控制 | `INIT`、`HEALTH`、`STATUS`、`GET_BACKEND_INFO`、`SHUTDOWN` | +| 执行 | `EXEC` | +| Session | `CREATE_SESSION` / `GET_SESSION` / `GET_SESSIONS` / `DELETE_SESSION` | +| Managed Process | `START_MANAGED_PROCESS` / `GET_MANAGED_PROCESS` / `STOP_MANAGED_PROCESS` | +| Skill | `LIST_SKILLS` / `GET_SKILL` / `CREATE_SKILL` / `UPDATE_SKILL` / `DELETE_SKILL` / `SCAN_SKILL_DIRECTORY` / `LIST_SKILL_FILES` / `READ_SKILL_FILE` / `WRITE_SKILL_FILE` / `PREVIEW_SKILL_ZIP` / `INSTALL_SKILL_ZIP` | + +### 3.6 Models (`box/models.py`, 331 行) + +核心数据模型: + +| 模型 | 用途 | +|------|------| +| `BoxNetworkMode` | `OFF` / `ON` | +| `BoxExecutionStatus` | `COMPLETED` / `TIMED_OUT` | +| `BoxHostMountMode` | `NONE` / `READ_ONLY` / `READ_WRITE` | +| `BoxManagedProcessStatus` | `RUNNING` / `EXITED` | +| `BoxMountSpec` | 单条挂载(host_path/mount_path/mode)— **新增** | +| `BoxSpec` | 执行请求;新增 `extra_mounts: list[BoxMountSpec]`、`persistent`、`workspace_quota_mb` | +| `BoxProfile` | 4 个内置 Profile + `locked` frozenset | +| `BoxSessionInfo` | Session 状态(含 backend_name/created_at/last_used_at) | +| `BoxManagedProcessSpec` | 长驻进程参数(process_id/command/args/env/cwd) | +| `BoxManagedProcessInfo` | 进程状态(status/exit_code/stderr_preview/attached) | +| `BoxExecutionResult` | 执行结果(status/exit_code/stdout/stderr/duration_ms) | + +`BoxSpec` 校验器: `workdir` 默认继承 `mount_path`;`host_path` 支持 POSIX 和 Windows 路径;设置 `host_path` 时 `workdir` 必须在 `mount_path` 下。 + +### 3.7 BoxSkillStore (`box/skill_store.py`, 647 行) + +新增模块(commit `4ab3502`),把 skill 持久化收归 Box runtime: + +``` +BoxSkillStore + ├─ list_skills() / get_skill(name) + ├─ create_skill(data) / update_skill(name, data) / delete_skill(name) + ├─ scan_skill_directory(path) 扫描目录返回候选 skill 包列表 + ├─ list_skill_files(name, path) 浏览 skill 内文件树 + ├─ read_skill_file(name, path) / write_skill_file(name, path, content) + ├─ preview_skill_zip(zip_bytes, ...) 不落盘预览 zip 内容 + └─ install_skill_zip(zip_bytes, ...) 解压、校验、复制到 skills_root + └─ 支持 source_subdir / target_suffix(commit 1aa043f) +``` + +GitHub 安装路径:HTTP 层(`api/http/service/skill.py`)先 `git clone` 拉取,再走 `install_skill_zip` 或 directory 路径。Skill 文件存放于 `box.local.skills_root`(默认 `skills`,相对 `host_root`),容器内对应 `/workspace/.skills/`。 + +### 3.8 Security (`box/security.py`, 52 行) + +`validate_sandbox_security()`: 黑名单校验 host_path,阻止挂载 `/etc`/`/proc`/`/sys`/`/dev`/`/root`/`/boot` 及 Docker/Podman socket。 + +**已知缺陷**: 根路径 `/` 未拦截,用户 home 目录未拦截,是 denylist 而非 allowlist 策略。详见 [SaaS 阻塞项 S5](./box-issues.md)。 + +### 3.9 Errors (`box/errors.py`, 33 行) + +| 异常类型 | 含义 | +|----------|------| +| `BoxError` | 基类 | +| `BoxValidationError` | spec/参数校验失败 | +| `BoxBackendUnavailableError` | 无可用 backend | +| `BoxRuntimeUnavailableError` | Runtime 服务不可用 | +| `BoxSessionConflictError` | session 已存在但 spec 不兼容 | +| `BoxSessionNotFoundError` | session 不存在 | +| `BoxManagedProcessConflictError` | session 已有同名 process | +| `BoxManagedProcessNotFoundError` | process 不存在 | + +--- + +## 4. 工具系统集成 + +### 4.1 ToolManager 编排 (`toolmgr.py`) + +``` +ToolManager.initialize() + ├─ NativeToolLoader (exec / read / write / edit / glob / grep) + ├─ PluginToolLoader (插件工具) + ├─ MCPLoader (MCP Server 工具) + ├─ SkillToolLoader (activate 工具 — Tool Call 激活) + └─ SkillAuthoringToolLoader (Skill CRUD) + +工具调用优先级: native → plugin → mcp → skill → skill_authoring +``` + +### 4.2 Native Tools (`native.py`, 846 行) + +| 工具 | 是否在 Box 中执行 | 是否访问宿主文件系统 | +|------|:---:|:---:| +| `exec` | 是 | 否 | +| `read` | **否** | **是** — 直接 `open()` 宿主文件 | +| `write` | **否** | **是** — 直接 `open()` 宿主文件 | +| `edit` | **否** | **是** — 直接 `open()` 宿主文件 | +| `glob` | **否** | **是** — 直接遍历宿主目录 | +| `grep` | **否** | **是** — 直接读宿主文件 | + +**沙箱边界不对称**: 这是刻意的设计权衡 — `read`/`write`/`edit`/`glob`/`grep` 绕过沙箱以获得性能(避免容器 I/O 开销与跨进程拷贝),但意味着 LLM 可以直接读写 `allowed_mount_roots` 下任何文件。Skill 路径经 `_resolve_host_path()` 重写,禁止穿越 `package_root`。 + +**exec 的 Skill 分支**: 命令中引用 `/workspace/.skills/` 的 skill 时: +1. 验证 skill 已激活 +2. 单次 exec 只能引用一个 skill 包 +3. 若 skill 是 Python 项目(有 `requirements.txt` 或 `pyproject.toml`),命令会被 venv bootstrap 包裹(在 skill 挂载点内创建 `.venv`) +4. 调用 `box_service.execute_tool()` → 走默认 session_id 与已组装好的 `extra_mounts`,**不再为每 skill 起独立 session** + +### 4.3 MCP-in-Box (`mcp_stdio.py`, 354 行) + +`BoxStdioSessionRuntime` 让 MCP stdio 服务器在 Box 容器中运行,**共享 session、多 process**模式(commit `529088e`): + +``` +initialize() + 1. 复用/创建共享 session (session_id = _build_box_session_id()) + - persistent=True,长期保持 + 2. workspace.execute_raw(install_cmd) 安装依赖 (可选) + 3. 将每个 MCP server 文件 stage 到 /workspace/.mcp// + 4. workspace.start_managed_process(process_id=) + 5. websocket_client(ws_url) 通过 WS relay 连接 + 6. ClientSession.initialize() MCP 协议握手 +``` + +配置 (`MCPServerBoxConfig`): `network='on'` (MCP 服务器通常需要网络),`host_path_mode='ro'` (默认只读),`startup_timeout_sec=120` (留时间给 pip install)。 + +每条 MCP server 是同一 session 中的一个 managed process,独立的 `process_id`、独立 attach URL,互不阻塞。 + +--- + +## 5. 启动与生命周期 + +### 5.1 启动顺序 (`build_app.py`) + +``` +BuildAppStage.run(ap) + ├─ ... (persistence, models, sessions) ... + │ + ├─ BoxService(ap) + ├─ box_service.initialize() + │ └─ connector.initialize() + │ ├─ [stdio] fork box subprocess + │ ├─ [subprocess+WS] Windows 本地 + │ └─ [remote WS] connect URL + │ └─ 启动心跳 _heartbeat_task + ├─ ap.box_service = box_service + │ + ├─ ToolManager(ap) + ├─ tool_mgr.initialize() + │ ├─ NativeToolLoader (检查 box_service.available) + │ ├─ PluginToolLoader + │ ├─ MCPLoader (Box 可用时,stdio MCP 走沙箱) + │ └─ SkillAuthoringToolLoader + ├─ ap.tool_mgr = tool_mgr + │ + ├─ ... (platform, pipeline) ... + ├─ SkillManager.initialize() (从 Box runtime 加载 skill 列表) + └─ ... (RAG, HTTP, plugins) ... +``` + +BoxService 在 ToolManager **之前**初始化。ToolManager 创建 loader 时检查 `box_service.available`。 + +### 5.2 初始化失败处理 + +```python +try: + await self._runtime_connector.initialize() + self._available = True +except Exception as e: + self._available = False + logger.warning(f"Box runtime unavailable: {e}") +``` + +**静默降级**: Box 初始化失败不会阻止应用启动,仅导致 6 个 native tool、所有 Skill 工具和 MCP-in-Box 工具不暴露给 LLM。与 Plugin 的行为不同(Plugin 失败会抛异常)。 + +### 5.3 销毁流程 + +``` +app.dispose() + └─ box_service.dispose() + ├─ connector.dispose() + │ ├─ cancel _heartbeat_task + │ ├─ cancel _handler_task / _ctrl_task + │ └─ terminate subprocess (SIGTERM) + └─ loop.create_task(client.shutdown()) + └─ RPC SHUTDOWN → Box Runtime 清理所有容器 +``` + +Box 额外做了 RPC SHUTDOWN 通知 Runtime 主动清理容器,比 Plugin 的直接杀进程更安全。 + +--- + +## 6. 配置 + +### config.yaml (重构后) + +```yaml +box: + enabled: true # 整个 Box 子系统的总开关。设为 false 时: + # - 不连接远程 Box runtime,不 fork 本地 stdio 子进程 + # - sandbox 工具 (exec/read/write/edit/glob/grep) 不暴露给 LLM + # - skill 添加/编辑 / GitHub 安装 / 文件写入全部拒绝 + # - stdio 模式的 MCP server 启动时报错(http/sse 模式不受影响) + # - skill 列表/读取保持只读可用 + # BOX__ENABLED 环境变量可覆盖(统一约定) + backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b' + # 由 box.backend / BOX__BACKEND 选择后端 + runtime: + endpoint: '' # 外部 Runtime 的 WS 基地址 'ws://host:5410' + # 留空 = 本地自管 Runtime + local: + profile: 'default' + image: '' # 覆盖 profile 默认 image + host_root: './data/box' # 工作区挂载根,Docker 部署需绝对路径 + default_workspace: '' # 默认 '/default' + skills_root: 'skills' # Box 管理的 skill 包目录(相对 host_root) + allowed_mount_roots: # 默认 [''] + - './data/box' + - '/tmp' + workspace_quota_mb: null # 配额覆盖,null = 走 profile + e2b: + api_key: '' # 也可走 E2B_API_KEY 环境变量 + api_url: '' # 自托管 E2B 时填写 + template: '' # 默认 template ID +``` + +> **重大变更**: 较 2026-04-16 文档,配置结构完全重组(commit `eefdea4`)。原字段 `box.profile` / `box.runtime_url` / `box.shared_host_root` / `box.allowed_host_mount_roots` 全部迁入 `box.local.*` 子表,新增 `box.backend` 与 `box.e2b.*` 配置组。 + +### docker-compose.yaml + +`langbot_box` 服务受 compose profile 控制,默认 `docker compose up` **不会**启动它。需要 sandbox 时: + +```bash +docker compose --profile box up # 启动 langbot + langbot_box + plugin runtime +docker compose --profile all up # 同上 +docker compose up # 只起 langbot + plugin runtime (box 关闭) +``` + +若不起 `langbot_box`,需要同步在 `data/config.yaml` 中设 `box.enabled: false`(或 langbot 容器 env 加 `BOX__ENABLED=false`),否则 LangBot 会一直尝试连接不存在的 Box runtime 并报错。 + +```yaml +# langbot_box 的关键 volume +volumes: + - ${LANGBOT_BOX_ROOT}:${LANGBOT_BOX_ROOT} # 工作区挂载(源/目标同路径) + - /var/run/docker.sock:/var/run/docker.sock # Docker backend 复用宿主 docker +``` + +### 关闭/连接失败时的行为矩阵 + +`box.enabled = false` 与"启用但连接失败"在用户可观察行为上**完全一致**——都通过 `BoxService.available = False` 表达,只是 `get_status` 多返回 `enabled` 字段供前端区分文案。 + +| 消费方 | Box 可用 | Box 不可用(disabled 或 failed) | +|---|---|---| +| native exec/read/write/edit/glob/grep 工具 | 暴露给 LLM | **不暴露** | +| `activate` / `register_skill` 工具 | 暴露给 LLM | **不暴露** | +| stdio MCP server | 在 Box 内启动 | **`_init_stdio_python_server` 抛 RuntimeError** 拒绝;不退化到宿主 stdio | +| http/sse MCP server | 正常 | 正常(不依赖 Box) | +| Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Box runtime | 走 LangBot 本地 `data/skills/` 只读 fallback | +| Skill 创建/编辑/安装/写文件 | 走 Box runtime | **HTTP 400** + 明确错误信息(`_require_box_for_write`) | +| Pipeline AI 配置中 `box-session-id-template` | 正常生效 | **前端 banner** 提示字段无效 | +| Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | **前端禁用** + banner | +| 仪表盘 Box 状态卡片 | 绿点 / "已连接" | 灰点 / "已禁用"(disabled) 或 红点 / "已断开"(failed) | + +> 后端拒写的边界条件:如果 `ap.box_service` **完全没装**(老式 dev mode,没经过 BuildAppStage),`_require_box_for_write` 视作 no-op,保留 `data/skills/` 本地路径——以兼容历史测试与最小化设置。生产环境总会装 `ap.box_service`,因此该 fallback 不会被触发。 + +### Pipeline 配置 (templates/metadata/pipeline/ai.yaml) + +`local-agent.config.box-session-id-template` 控制 session 作用域,预设: + +- `{launcher_type}_{launcher_id}` — 每个会话 (推荐,默认) +- `{launcher_type}_{launcher_id}_{sender_id}` — 群聊每个用户 +- `{launcher_type}_{launcher_id}_{conversation_id}` — 每个对话上下文 +- `{query_id}` — 每条消息(完全隔离) + +详见 [box-session-scope.md](./box-session-scope.md)。 + +### REST API + +| 端点 | 方法 | 说明 | 前端 | +|------|------|------|:---:| +| `/api/v1/box/status` | GET | 可用性、Profile、后端信息 | ✅ 监控页 | +| `/api/v1/box/sessions` | GET | 活跃 session 列表 | ❌ | +| `/api/v1/box/errors` | GET | 最近 50 条错误 | ❌ | +| `/api/v1/skills` 等 | GET/POST/PUT/DELETE | Skill CRUD、文件浏览、zip/GitHub 安装、preview | ✅ Skill 管理页 | + +前端 `web/src/app/home/monitoring/components/overview-cards/SystemStatusCards.tsx` 已接入 `/api/v1/box/status`,展示 backend 名称、profile 与活跃 session 数。Sessions 与 errors API 仍未接入。 diff --git a/docs/review/box-issues.md b/docs/review/box-issues.md new file mode 100644 index 000000000..15650c7c5 --- /dev/null +++ b/docs/review/box-issues.md @@ -0,0 +1,76 @@ +# Box 系统 — SaaS 发布前阻塞项 + +> 更新日期: 2026-06-02 +> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) +> 相关文档: [架构分析](./box-architecture.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md) + +## 范围说明 + +**自部署社区版已具备发布条件**:默认 stdio 模式、box 为可选项;box 关闭 / 不可用时后端、前端、工具、skill、stdio-MCP 均能干净降级(清晰报错、不崩溃);配置向后兼容(旧 `data/config.yaml` 可直接启动);无新增 ORM 模型、无迁移欠债;市场安装失败不会破坏实例。CI 全绿。 + +本清单**只保留发布 SaaS / 多租户 / 公网暴露前必须处理的阻塞项**。社区版(可信、单运营者、内网)不受这些项阻塞——它们的风险面在"不可信调用方能直接触达 Box 控制面"或"多租户共享资源"的场景才成立。 + +## 已解决(社区版发布前) + +| 项 | 处理 | +|----|------| +| 工具调用循环无上限 (原 #13) | `localagent.py` 增加 `MAX_TOOL_CALL_ROUNDS=128`,超限优雅终止(`cafef1a3`) | +| 配额校验同步遍历阻塞事件循环 (原 #10) | `_enforce_workspace_quota` 改 async,工作区遍历走 `asyncio.to_thread`(`cafef1a3`) | +| `host_path` 挂载白名单 (原 #3 的 LangBot 侧) | `pkg/box/service.py` `allowed_mount_roots` 白名单,空列表时拒绝一切宿主挂载 | +| 重复的 `_is_path_under` (原 #12) | 已去重,仅保留一处定义 | +| 重连 / 心跳 / Windows 兼容 / nsjail image 字段 / 前端 Box 状态接入 | 见上一轮 review 记录,均已合入 | + +--- + +## SaaS 阻塞项 + +### S1. Box 控制面无认证 — Critical + +- **位置**: SDK `box/server.py` — Action RPC WS (`/rpc/ws`) 与 managed-process relay (`/v1/sessions/{id}/managed-process/{pid}/ws`) +- **现状**: 两个 WS handler 在 `ws.prepare` 后直接服务,无任何 token / 鉴权;box 默认绑定 `0.0.0.0:5410`。任何能触达该端口者可发起 `EXEC`、创建 session、attach 任意 session 的 managed-process stdin/stdout、甚至 `SHUTDOWN`。LangBot→box 的 INIT 也未下发任何凭证。 +- **缓解现状**: 默认 `docker-compose.yaml` 的 `langbot_box` 未把 5410 发布到宿主(爆炸半径限于内网 bridge);但 box 挂载了 `/var/run/docker.sock`,同网络的任意服务(含被攻破的插件)→ 宿主 root。若运营者把 5410 发布到宿主或独立以 `0.0.0.0` 起 box,则完全裸奔。 +- **要求**: INIT 时下发 token,两个 WS 路由按连接校验(query/header)。这是 SaaS 的**头号**阻塞项。 + +### S2. 无 exec 授权模型(policy.py 死代码) — High + +- **位置**: LangBot `pkg/box/policy.py`(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy` 全项目无引用);`pkg/provider/tools/loaders/native.py`;`pkg/provider/tools/toolmgr.py` +- **现状**: 原生工具(`exec/read/write/edit/glob/grep`)按"box 是否可用"全有或全无地暴露,**无 per-pipeline 的 exec 网关 / 工具白名单 / 沙箱模式 / 权限提升控制**。只要 box 可用,任何使用 local-agent + 函数调用模型的 pipeline 都能跑任意 shell。 +- **要求**: 接入 policy.py(或等价机制),按 pipeline 控制是否暴露 `exec`、可用工具白名单、沙箱网络/只读模式。 + +### S3. 会话资源无界(DoS) — High + +- **#5 session 数量无上限**: SDK `box/runtime.py` `_get_or_create_session` 的 `_sessions` dict 无容量限制——可变 `session_id` 的恶意调用可无限创建容器,耗尽宿主 CPU/内存/PID/磁盘。 +- **#8 无定时回收**: 过期 session 仅在 `_get_or_create_session` 时机会性清理,无独立周期任务;一波创建后转静默会永久泄漏容器。 +- **要求**: `max_sessions` 上限(拒绝或 LRU),加独立周期 reaper(如 60s)。 + +### S4. 工作区配额无内核级限制(TOCTOU) — Med-High + +- **位置**: LangBot `pkg/box/service.py` `_enforce_workspace_quota`(应用层 read-then-check);SDK 侧 `workspace_quota_mb` 仅记录/透传,无 `--storage-opt size=` 等内核/FS 限额 +- **现状**: 执行前后两次检查之间存在竞态窗口;单条命令(`dd`/`fallocate`)可在检查间隙撑爆磁盘,事后检查只能补救。 +- **要求**: Docker `--storage-opt size=` 做内核级限制,或 Redis 原子计数预留式配额。 + +### S5. 挂载校验缺口 — Med-High + +- **位置**: SDK `box/security.py` `_BLOCKED_HOST_PATHS_POSIX`;`box/backend.py` 的 `extra_mounts` 处理 +- **现状**: ① SDK 黑名单仍不含 `/`(前缀匹配,`host_path="/"` 可通过,挂载整个宿主 fs);用户 home、`/usr`、`/opt`、`/tmp` 也未拦截。② `validate_sandbox_security` 只校验 `spec.host_path`,**从不遍历 `spec.extra_mounts`**——LangBot 侧 `allowed_mount_roots` 也只校验 `host_path`。当前 `extra_mounts` 仅由 `build_skill_extra_mounts` 内部填充(agent 不可达),但缺乏纵深防御:一旦 S1 的无认证 RPC 被触达,extra_mounts 可挂任意宿主路径,两层都不拦。 +- **要求**: SDK 黑名单加入 `/`(或改白名单);`extra_mounts` 在 SDK 与 LangBot 两侧都纳入挂载校验。 + +### S6. 容器加固缺失 — Med + +- **位置**: SDK `box/backend.py` 的 `docker run` 组装 +- **现状**: 未设置 `--cap-drop=ALL`、`--security-opt=no-new-privileges`、非 root `--user`;叠加挂载 docker.sock,逃逸面偏大。 +- **要求**: 默认加上上述加固 flag(需回归常用 skill 不被破坏)。 + +### S7. 全局锁内执行慢操作(扩展性) — Med + +- **位置**: SDK `box/runtime.py` `_get_or_create_session`:`self._lock` 持有期间调用 `backend.start_session()`(`docker run` / nsjail 启动 / E2B `Sandbox.create`) +- **影响**: 冷启动(镜像拉取数秒、E2B >1s)期间串行阻塞所有并发请求——多租户负载下整个 Box runtime 停顿。降级表现是延迟而非失败。 +- **要求**: 锁内只做状态检查与注册,容器创建移到锁外。 + +### S8. 其他硬化 / 跟进 — Low + +- **#9** SDK `box/server.py` 直接读 `runtime._sessions` 私有字段、绕过锁,并发下可能读到不一致状态——应加公共访问方法。 +- **#16** `pkg/provider/tools/toolmgr.py` `execute_func_call` 按优先级分发,plugin/MCP 若有同名 `exec/read/write/...` 工具会被静默遮蔽——应加命名空间或冲突告警。 +- **#4** SDK `box/runtime.py` INIT/handshake 与 backend 实例化的残留竞态(仅"纯远程 WS box 先启动、LangBot 后连"场景成立;stdio/compose 路径下 config 经 env 在 spawn 时已就位,无竞态)——应在 INIT 完成前拒绝业务 action。 +- **#11** `extra_mounts` 在容器创建时固定(SDK `runtime.py` 兼容性检查不含 extra_mounts);长生命周期共享 session 后续新激活的 skill 不会挂上(当前缓解:创建时挂上 pipeline 绑定的全部 skill)——动态绑定场景需销毁重建或文档说明。 +- **#21** 集成测试未进 CI:容器实际执行、E2B 真机、managed-process WS attach 仅本地可跑。安全关键路径缺自动化覆盖——SaaS 前建议加 Docker-in-Docker CI stage 或合并前手动 checklist。 diff --git a/docs/review/box-session-scope.md b/docs/review/box-session-scope.md new file mode 100644 index 000000000..bb92265de --- /dev/null +++ b/docs/review/box-session-scope.md @@ -0,0 +1,402 @@ +# Box Session Scope Design + +> Date: 2026-04-18 (last reviewed 2026-06-02) +> Status (2026-06-02): the self-hosted community edition is release-ready (box optional, clean degradation, no migration debt). Tool-call loop cap, async quota scan, and the host_path mount allowlist have landed. Remaining multi-tenant / security hardening is tracked in [box-issues.md](./box-issues.md). +> Branch: `feat/sandbox` (LangBot + langbot-plugin-sdk) +> Related: [Box Architecture](./box-architecture.md) | [Box vs Plugin Runtime](./box-vs-plugin-runtime.md) + +--- + +## 0. Implementation Status (2026-05-19) + +This document was authored as a design proposal. The current `feat/sandbox` branch +has shipped the design largely as written: + +| Item | Status | Notes | +|------|--------|-------| +| `BoxMountSpec` + `BoxSpec.extra_mounts` | ✅ Shipped | SDK `box/models.py` | +| Docker / nsjail / E2B backends apply extra mounts | ✅ Shipped | Last gap closed by SDK commit `0fea9b1` (E2B) | +| `box-session-id-template` in `local-agent` pipeline config | ✅ Shipped | `templates/metadata/pipeline/ai.yaml`, default `{launcher_type}_{launcher_id}` | +| `BoxService.resolve_box_session_id(query)` | ✅ Shipped | `pkg/box/service.py:166` | +| `BoxService.build_skill_extra_mounts(query)` | ✅ Shipped | `pkg/box/service.py:189` | +| Skill exec uses unified container + extra mounts | ✅ Shipped | `pkg/provider/tools/loaders/native.py` skill branch | +| MCP-in-Box uses shared persistent session, multi-process | ✅ Shipped (earlier than originally scoped) | SDK commit `529088e`, LangBot `mcp_stdio.py:_build_box_session_id` | +| `BoxManagedProcessSpec.process_id` + multi-process per session | ✅ Shipped | `BoxRuntime` keeps `managed_processes: dict[pid, _ManagedProcess]` | +| Per-tenant / quota integration with templates | ❌ Not started | See [box-tob-analysis.md](./box-tob-analysis.md) | + +The "Phase 2 deferred" note in §10 is **out of date** — MCP unification went in on +the same line. Pipeline-scoped (not user-scoped) MCP container is the realized +behavior: each pipeline's MCP servers share one `mcp-` session, and +user exec sessions use the template-derived id. + +The remaining open work is multi-tenant overlays (tenant_id in session_id, +quota counters keyed by tenant), tracked in the toB analysis doc rather than here. + +--- + +## 1. Problems + +### 1.1 Default exec: per-message containers + +Currently, `BoxService.execute_tool()` sets `session_id = str(query.query_id)` — an +auto-incrementing integer per incoming message. Every user message creates a new sandbox +container. Dependencies installed and in-container state are lost between messages. + +### 1.2 Three isolated container pools + +Default exec, skills, and MCP servers each manage their own containers with +independent session IDs: + +| Path | Session ID | Container | +|--------------|-----------------------------------------------|-------------| +| Default exec | `str(query_id)` (per message) | Ephemeral | +| Skill exec | `skill-{launcher}_{id}-{skill_name}` | Per skill | +| MCP stdio | `mcp-{server_uuid}` | Per server | + +This means a single logical user interaction can spawn 3+ containers that cannot +share state, see each other's files, or reuse installed dependencies. + +### 1.3 Single bind mount limitation + +`BoxSpec` currently supports only **one** `host_path` → `mount_path` bind mount. +This prevents mounting both a default workspace and skill directories into the +same container. + +--- + +## 2. Concept Model + +``` +Platform Message + → Query (query_id: int, auto-increment, per message) + → Session (launcher_type + launcher_id, per chat window) + → Conversation (uuid, per dialogue context within a Session) +``` + +| Concept | Key | Example | Scope | +|---------------|-------------------------------------|----------------------------|------------------------------| +| Query | `query_id` | `42` | Single message | +| Session | `launcher_type` + `launcher_id` | `group_123456` | Chat window (group or PM) | +| Conversation | `conversation_id` (UUID) | `a1b2c3d4-...` | Dialogue context within a Session | +| Sender | `sender_id` | `789` | Individual user | + +Note: in a **group chat**, all users share the same Session (keyed by `group_id`). The +individual sender is tracked as `sender_id` but does not affect Session/Conversation routing. + +--- + +## 3. Target Scenarios + +| # | Scenario | Box Granularity | Desired `session_id` | +|----|--------------------------------|------------------------------------------|---------------------------------------------------------| +| 1 | Personal assistant | 1 Box per user, long-lived | `{launcher_type}_{launcher_id}` | +| 2 | Customer service | 1 Box per customer, cross-pipeline | `{launcher_type}_{launcher_id}` | +| 3 | Internal employee tool | 1 Box per employee | `{launcher_type}_{launcher_id}` | +| 4 | Group chat shared assistant | 1 Box per group | `{launcher_type}_{launcher_id}` | +| 5 | Group chat isolated per user | 1 Box per user within a group | `{launcher_type}_{launcher_id}_{sender_id}` | +| 6 | Teaching (cross-channel) | 1 Box per student across groups/PMs | `{sender_id}` | +| 7 | One-off execution | 1 Box per message (current behavior) | `{query_id}` | +| 8 | Multi-project development | 1 Box per conversation context | `{launcher_type}_{launcher_id}_{conversation_id}` | + +No single fixed granularity covers all scenarios. A template-based approach is needed. + +--- + +## 4. Design Overview + +Two key changes: + +1. **Unified container**: exec, skills, and MCP all share the same container per + session scope. No more separate container pools. +2. **Configurable session scope**: `session_id` is generated from a template with + pipeline variables, configurable per pipeline. + +### 4.1 Unified Container with Multiple Mounts + +A single container per session scope is created on first use. It has: + +- **Primary mount**: default workspace at `/workspace` (from `default_host_workspace`) +- **Skill mounts**: each pipeline-bound skill's `package_root` mounted at + `/workspace/.skills/{skill_name}/` +- **MCP servers**: run as managed processes inside the same container + +``` +Container (session_id = "group_123456") + /workspace/ ← default workspace (bind mount, rw) + /workspace/.skills/web-search/ ← skill package (bind mount, rw) + /workspace/.skills/data-analysis/ ← skill package (bind mount, rw) + [managed process: mcp-server-a] ← MCP server running inside + [managed process: mcp-server-b] ← MCP server running inside +``` + +This requires extending `BoxSpec` to support multiple mounts (see §5). + +### 4.2 Session ID Template + +A new field `box-session-id-template` in the `local-agent` pipeline runner config +controls the session scope: + +```yaml +# templates/metadata/pipeline/ai.yaml (under local-agent.config) +- name: box-session-id-template + label: + en_US: Sandbox Scope + zh_Hans: 沙箱作用域 + description: + en_US: >- + Determines how sandbox environments are shared. Use variables to + control isolation granularity. + zh_Hans: >- + 决定沙箱环境的共享方式。使用变量控制隔离粒度。 + type: select + required: false + default: "{launcher_type}_{launcher_id}" + options: + - value: "{launcher_type}_{launcher_id}" + label: + en_US: Per chat (Recommended) + zh_Hans: 每个会话(推荐) + - value: "{launcher_type}_{launcher_id}_{sender_id}" + label: + en_US: Per user in chat + zh_Hans: 会话中每个用户 + - value: "{launcher_type}_{launcher_id}_{conversation_id}" + label: + en_US: Per conversation context + zh_Hans: 每个对话上下文 + - value: "{query_id}" + label: + en_US: Per message (isolated) + zh_Hans: 每条消息(完全隔离) +``` + +Available template variables (populated by PreProcessor in `query.variables`): + +| Variable | Source | Example | +|---------------------|---------------------------------|----------------------| +| `{launcher_type}` | `query.session.launcher_type` | `person` / `group` | +| `{launcher_id}` | `query.session.launcher_id` | `123456` | +| `{sender_id}` | `query.sender_id` | `789` | +| `{conversation_id}` | `conversation.uuid` | `a1b2c3d4-...` | +| `{query_id}` | `query.query_id` | `42` | + +Default `{launcher_type}_{launcher_id}` covers scenarios 1–4 out of the box. + +--- + +## 5. SDK Changes: Multi-Mount BoxSpec + +### 5.1 Model Extension + +```python +# box/models.py + +class BoxMountSpec(pydantic.BaseModel): + """A single bind mount specification.""" + host_path: str + mount_path: str + mode: BoxHostMountMode = BoxHostMountMode.READ_WRITE + +class BoxSpec(pydantic.BaseModel): + # ... existing fields ... + host_path: str | None = None # Primary mount (backward compat) + host_path_mode: BoxHostMountMode = BoxHostMountMode.READ_WRITE + mount_path: str = DEFAULT_BOX_MOUNT_PATH + extra_mounts: list[BoxMountSpec] = [] # NEW: additional mounts +``` + +`extra_mounts` is additive — the existing `host_path` / `mount_path` pair remains +the primary mount for backward compatibility. + +### 5.2 Backend: Apply Extra Mounts + +```python +# box/backend.py — CLISandboxBackend.start_session() + +# Primary mount (unchanged) +if spec.host_path is not None and spec.host_path_mode != BoxHostMountMode.NONE: + args.extend(['-v', f'{spec.host_path}:{spec.mount_path}:{spec.host_path_mode.value}']) + +# Extra mounts (NEW) +for mount in spec.extra_mounts: + if mount.mode != BoxHostMountMode.NONE: + args.extend(['-v', f'{mount.host_path}:{mount.mount_path}:{mount.mode.value}']) +``` + +Same pattern for nsjail backend. + +--- + +## 6. LangBot Changes + +### 6.1 Session ID Resolution + +In `BoxService.execute_tool()`: + +```python +# Before: +spec_payload.setdefault('session_id', str(query.query_id)) + +# After: +template = (query.pipeline_config or {}).get('ai', {}) \ + .get('local-agent', {}).get('box-session-id-template', + '{launcher_type}_{launcher_id}') +variables = query.variables or {} +session_id = template.format_map(collections.defaultdict( + lambda: 'unknown', variables +)) +spec_payload.setdefault('session_id', session_id) +``` + +### 6.2 Skill Exec: Use Same Container + +Currently `native.py:_invoke_exec` creates a separate `BoxWorkspaceSession` per +skill with `host_path=package_root`. Instead: + +1. Use the **same session_id** as default exec (from the template). +2. Pass the skill's `package_root` as an **extra mount** at + `/workspace/.skills/{skill_name}/` instead of replacing `/workspace`. +3. The container already has the default workspace at `/workspace`. + +```python +# native.py — _invoke_exec, skill branch (REVISED) + +# Same session_id as default exec +session_id = resolve_box_session_id(query) + +spec_payload = { + 'cmd': rewritten_command, + 'workdir': rewritten_workdir, + 'session_id': session_id, + 'extra_mounts': [{ + 'host_path': package_root, + 'mount_path': f'/workspace/.skills/{selected_skill_name}', + 'mode': 'rw', + }], +} +result = await self.ap.box_service.execute_spec_payload(spec_payload, query) +``` + +The virtual path `/workspace/.skills/{name}` no longer needs rewriting at the +command level — it maps directly to the bind mount path inside the container. + +### 6.3 MCP: Use Same Container + +MCP servers should run inside the same container as exec and skills. Changes: + +1. `BoxStdioSessionRuntime` uses the pipeline's session_id template instead of + `mcp-{server_uuid}`. +2. MCP server's working directory is a subdirectory (e.g. `/workspace/.mcp/{name}/`). +3. MCP server's dependencies are mounted or installed into that subdirectory. +4. The MCP server runs as a managed process inside the shared container. + +Since MCP servers start at LangBot boot (not per-query), the session must be +created eagerly. The container will be kept alive by the managed process +exemption in TTL reaping (`runtime.py:259`). + +**Note**: MCP sessions are pipeline-scoped (not per-launcher), so their session_id +should be a **fixed identifier per pipeline** rather than the user-facing template. +This means one shared MCP container per pipeline, with user exec sessions separate. + +Alternatively, in a future iteration, MCP managed processes could be launched +lazily into the user's container on first MCP tool call. This is more complex +but maximizes sharing. For V1, keeping MCP containers at pipeline scope is +simpler and more predictable. + +--- + +## 7. Mount Layout Summary + +### Default exec (no skills activated) + +``` +Container (session_id from template) + /workspace/ ← default_host_workspace (rw) +``` + +### Exec with activated skills + +``` +Container (same session_id) + /workspace/ ← default_host_workspace (rw) + /workspace/.skills/web-search/ ← skill package_root (rw) + /workspace/.skills/data-analysis/ ← skill package_root (rw) +``` + +Extra mounts are **additive** — they are added when the container is first +created (or on the first exec that references a skill). Since Docker bind +mounts are specified at container creation time, skills must be known at +creation time. + +**Resolution**: When creating a container, inject `extra_mounts` for **all +pipeline-bound skills** (from `extensions_preferences`), not just the +currently activated one. This way any skill can be activated later without +recreating the container. + +### MCP servers (V1: pipeline-scoped) + +``` +Container (session_id = "mcp-pipeline-{pipeline_uuid}") + /workspace/ ← MCP shared workspace + /workspace/.mcp/server-a/ ← MCP server A files + /workspace/.mcp/server-b/ ← MCP server B files + [managed process: server-a] + [managed process: server-b] +``` + +--- + +## 8. Data Migration + +Existing pipelines do not have `box-session-id-template`. The backend uses +`.get(..., default)` so missing keys fall back to `{launcher_type}_{launcher_id}`. +This changes behavior from per-message to per-launcher for existing pipelines. + +Recommendation: **accept the behavior change** — per-launcher is the more +intuitive default, and the old per-message behavior was rarely desired. + +--- + +## 9. Cloud Quota Implications + +| Scope | Typical concurrent containers | +|-----------------------------------------------|-------------------------------| +| `{query_id}` (per message) | Many, short-lived | +| `{launcher_type}_{launcher_id}` (per chat) | = active chat count | +| `{sender_id}` (per user) | = active user count | +| `{conversation_id}` (per conversation) | Between per-chat and per-msg | + +With the unified container model, each scope value maps to exactly **one** +container (instead of potentially 3+ per-message). This significantly reduces +resource usage. + +Quota enforcement point: `BoxRuntime._get_or_create_session()` in the SDK. + +--- + +## 10. Implementation Phases + +### Phase 1: Session scope + skill unification (this PR) + +1. **SDK**: Extend `BoxSpec` with `extra_mounts: list[BoxMountSpec]`. +2. **SDK**: Update Docker/nsjail backends to apply extra mounts. +3. **LangBot**: Add `box-session-id-template` to `local-agent` YAML metadata + and default pipeline config JSON. +4. **LangBot**: Update `BoxService.execute_tool()` to use template interpolation. +5. **LangBot**: Update `native.py:_invoke_exec` skill branch to use same + session_id + extra mounts instead of separate `BoxWorkspaceSession`. +6. **LangBot**: On container creation, inject extra mounts for all + pipeline-bound skills. +7. **Frontend**: No code change — `DynamicFormComponent` renders `select` fields. +8. **Tests**: Unit tests for template interpolation and multi-mount specs. + +### Phase 2: MCP unification (future) + +1. Refactor `BoxStdioSessionRuntime` to use pipeline-scoped shared container. +2. MCP servers become managed processes in the shared container. +3. Support multiple concurrent managed processes per container. + +MCP unification is deferred because it requires changes to the managed process +model (currently 1 managed process per session) and has startup ordering +concerns (MCP servers start at boot, before any user query determines +a session_id). diff --git a/docs/review/box-test-coverage.md b/docs/review/box-test-coverage.md new file mode 100644 index 000000000..995e6970b --- /dev/null +++ b/docs/review/box-test-coverage.md @@ -0,0 +1,122 @@ +# Box 系统测试覆盖分析 + +> 更新日期: 2026-06-02 +> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。 +> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) + +--- + +## 1. 测试文件清单 + +### LangBot 仓库 + +| 文件 | 行数 | CI 运行 | 覆盖范围 | +|------|------|---------|---------| +| `tests/unit_tests/box/test_box_connector.py` | 106 | 是 | Connector 传输决策、WS relay URL、dispose、心跳/重连 | +| `tests/unit_tests/box/test_box_service.py` | 1224 | 是 | Service 核心逻辑(最全面) | +| `tests/unit_tests/box/test_workspace.py` | 147 | 是 | WorkspaceSession 路径重写、payload 构建 | +| `tests/unit_tests/provider/test_mcp_box_integration.py` | 707 | 是 | MCP Box 配置、路径重写、payload、shared-session/multi-process、runtime info | +| `tests/unit_tests/provider/test_localagent_sandbox_exec.py` | 444 | 是 | LocalAgent exec 流程、流式、Skill 激活 (Tool Call) | +| `tests/unit_tests/provider/test_tool_manager_native.py` | 249 | 是 | ToolManager 路由、native tool CRUD、路径穿越、6 工具暴露 | +| `tests/unit_tests/provider/test_skill_tools.py` | 582 | 是 | Skill 管理、Tool Call 激活、路径、authoring CRUD | +| `tests/unit_tests/test_skill_service.py` | 396 | 是 | HTTP service:skill CRUD、zip/GitHub install、文件浏览 | +| `tests/unit_tests/test_paths.py` | 23 | 是 | paths 工具 | +| `tests/unit_tests/test_preproc.py` | 134 | 是 | PreProcessor 注入 session 变量、bound skill 解析 | +| `tests/unit_tests/pipeline/test_chat_handler_logging.py` | 78 | 是 | Chat handler 日志相关回归 | +| `tests/integration_tests/box/test_box_integration.py` | 329 | **否** | 真实容器执行、超时、网络隔离 | +| `tests/integration_tests/box/test_box_mcp_integration.py` | 368 | **否** | Managed process、WS attach、shared-session 清理 | + +### SDK 仓库 + +| 文件 | 行数 | CI 运行 | 覆盖范围 | +|------|------|---------|---------| +| `tests/box/test_backend_selection.py` | 255 | 是 | 显式 backend / local 模式探测顺序 / 配置变更触发 reselect | +| `tests/box/test_nsjail_backend.py` | 452 | 是 | nsjail 可用性、安装版 CLI vs 容器内 CLI、session、arg 构建、资源限制 | +| `tests/box/test_e2b_backend.py` | 482 | 是 | E2B SDK mock、session 生命周期、extra_mounts 同步 | +| `tests/box/test_skill_store.py` | 88 | 是 | zip preview/install、基础 file CRUD | + +**总计**: 17 个测试文件, ~6,500 行测试代码; 其中 2 个集成测试(约 700 行)在 CI 中不运行。 + +> 较 2026-04-16 版增加:`test_skill_service.py`、`test_paths.py`、`test_preproc.py`、`test_chat_handler_logging.py` (LangBot),`test_backend_selection.py`、`test_e2b_backend.py`、`test_skill_store.py` (SDK)。`test_nsjail_backend.py` 增加 CLI 兼容性 case (commit `feed530`)。 + +--- + +## 2. 覆盖良好的区域 + +| 区域 | 质量 | 说明 | +|------|------|------| +| BoxRuntime session 管理 | 优秀 | session 复用、冲突检测、TTL 配置、消失 session 重建 | +| BoxService Profile 系统 | 优秀 | 4 个内置 Profile、locked/unlocked 字段、timeout clamp | +| BoxService host mount 安全 | 优秀 | allowed_mount_roots、disallowed_roots、shared host root | +| BoxService workspace quota | 优秀 | 前置/后置配额检查、超额清理 | +| BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr | +| BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 | +| BoxService session 模板 | 良好 | `resolve_box_session_id` + `build_skill_extra_mounts` 在 service / native / mcp 三处都有覆盖 | +| RPC client/server 协议 | 优秀 | execute/get_sessions/delete/create/conflict error | +| BoxRuntimeConnector | 良好 | local/remote 模式、Docker 平台、relay URL、心跳与重连回调 | +| BoxWorkspaceSession | 良好 | payload 构建、managed process 路径重写、stage host file | +| BoxHostMountMode.NONE | 良好 | 枚举校验、workdir 约束 | +| NsjailBackend | 良好 | 可用性、安装版 vs 容器内、session 生命周期、arg 构建、资源限制 | +| E2BBackend | 良好 | mock SDK、session/extra_mounts 同步 | +| Backend selection | 良好 | 显式 backend 优先级、local 探测顺序、配置变更触发 reselect | +| MCP Box 集成 | 良好 | config model、路径重写、payload、shared-session 多 process | +| Native tool loader | 良好 | 6 工具(exec/read/write/edit/glob/grep)、路径穿越拦截 | +| LocalAgent exec 流程 | 良好 | 完整 tool call 循环、流式、system prompt 注入、Tool Call 激活 | +| Skill 系统 | 良好 | 加载、Tool Call 激活、marker、路径解析、authoring CRUD、HTTP service | + +--- + +## 3. 覆盖缺失的区域 + +### 3.1 零测试 / 严重不足 + +| 区域 | 源文件 | 影响 | +|------|--------|------| +| **`security.py`** | SDK `box/security.py` (52 行) | `validate_sandbox_security()` 无任何测试。阻止 `/etc`/`/proc`/Docker socket 等危险挂载的安全函数从未被验证 | +| **`policy.py`** | `pkg/box/policy.py` (98 行) | 三层安全策略无测试(也是死代码) | +| **`skill_store.py` 边缘场景** | SDK `box/skill_store.py` (647 行) vs 测试 88 行 | GitHub 安装路径、`source_subdir` / `target_suffix` 组合、损坏 zip、文件冲突等场景未覆盖 | + +### 3.2 未测试的关键路径 + +| 区域 | 说明 | +|------|------| +| **Session TTL 过期** | 测试配置了 `session_ttl_sec` 但从未推进时间验证过期清理 | +| **并发 session 访问** | 无并发 exec / 并发创建 / race condition 测试 | +| **Container backend (Docker)** | 仅通过集成测试覆盖(CI 不运行),单元测试全用 FakeBackend | +| **E2B 真实 sandbox** | 单测全是 mock,未对接真实 E2B API | +| **BoxRuntime shutdown()** | 在 test cleanup 中调用但未验证行为 | +| **BoxServerHandler 错误路径** | 畸形请求、未知 action 类型 | +| **WS relay** | 仅在集成测试中覆盖(CI 不运行) | +| **NsjailBackend managed process** | 完全未测试 | +| **MCP stdio 完整生命周期** | 依赖安装 → 进程启动 → 健康检查 → 多 process 并发 → 重试 | +| **BoxService start/stop_managed_process** | 单 process 流转有单测,多 process 互不阻塞主要靠集成测试 | +| **重连指数退避** | connector 单测覆盖回调接线,未实际跑完整重连周期 | + +### 3.3 边缘情况缺失 + +| 区域 | 说明 | +|------|------| +| BoxSpec 校验 | 无效 session_id 格式、超长命令、env 特殊字符 | +| BoxSpec.extra_mounts | 重复 mount_path、与 host_path 冲突、绝对 vs 相对路径 | +| BoxExecutionResult | 仅 COMPLETED 和 TIMED_OUT,无 ERROR 状态测试 | +| 多后端 fallback | local 模式探测顺序仅靠 mock,无真实 Docker 不可用 → nsjail 真机 fallback 测试 | +| Profile YAML 加载 | 测试用硬编码字符串,未从真实 config.yaml 加载 | +| INIT 配置变更触发 backend 重建 | 单测仅在初始化场景验证 | + +--- + +## 4. 集成测试 vs CI 的差距 + +CI 仅运行 `tests/unit_tests/`,以下场景**从未在自动化中验证**: + +- 真实容器的创建/执行/销毁 +- 容器网络隔离(`--network none`) +- 容器资源限制生效(cpus/memory/pids_limit) +- Managed process 的 WS 双向 I/O +- 多 process 同 session 并发 I/O +- 孤儿容器清理 +- Session 删除清理容器 +- 进程退出检测 +- E2B 真实 sandbox 行为 + +**建议**: 在 CI 中加一个可选的 Docker-in-Docker 集成测试 stage,至少覆盖核心执行路径(exec / MCP attach / session 销毁)。 diff --git a/docs/review/box-tob-analysis.md b/docs/review/box-tob-analysis.md new file mode 100644 index 000000000..a49544c98 --- /dev/null +++ b/docs/review/box-tob-analysis.md @@ -0,0 +1,167 @@ +# Box 系统 toB 商业化分析 + +> 更新日期: 2026-06-02 +> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。 +> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) + +--- + +## 1. 现有优势 + +| 能力 | toB 价值 | 代码位置 | +|------|---------|---------| +| **沙箱隔离执行** | 企业安全运行不受信代码的基础能力 | SDK `box/backend.py` | +| **多后端支持** | 适配不同企业容器基础设施 (Podman/Docker/nsjail/E2B) | SDK `box/runtime.py` `_select_backend()` | +| **E2B 云沙箱** | SaaS / 无 Docker 部署的兜底执行环境 | SDK `box/e2b_backend.py` | +| **连接自愈** | 心跳 + 自动重连,单点 Box runtime 故障可恢复 | `pkg/box/connector.py` `_heartbeat_loop`, `pkg/box/service.py` `_reconnect_loop` | +| **Profile + locked 字段** | 运维锁定安全边界,LLM/用户无法绕过 | `pkg/box/service.py`, SDK `box/models.py` | +| **资源限制** | CPU/内存/PID 数限制防止资源滥用 | SDK `backend.py` `--cpus/--memory/--pids-limit` | +| **Workspace quota** | 磁盘用量控制 | `pkg/box/service.py` `_enforce_workspace_quota` | +| **静默降级** | Box 不可用不影响其他功能,降低部署门槛 | `pkg/box/service.py:78` `_available=False` | +| **孤儿容器清理** | 防止泄漏的容器持续占用资源 | SDK `backend.py` `cleanup_orphaned_containers` | +| **网络隔离** | `--network none` 防止数据外泄 | SDK `backend.py` start_session | +| **只读根文件系统** | `--read-only` 防止容器被持久篡改 | SDK `backend.py` start_session | +| **Host path 白名单** | `allowed_host_mount_roots` 限制可挂载目录 | `pkg/box/service.py` `_validate_host_mount` | + +--- + +## 2. toB 差距分析 + +### 2.1 安全与合规 + +| 维度 | 现状 | toB 要求 | 优先级 | +|------|------|---------|--------| +| **WS relay 认证** | 无认证,任何人可 attach | 至少 token 认证 | **P0** | +| **安全策略** | policy.py 是死代码,实际无细粒度控制 | 工具级 allow/deny、沙箱模式控制 | **P0** | +| **审计日志** | 仅内存中 50 条 `_recent_errors` | 持久化审计:谁何时执行了什么、结果如何 | **P0** | +| **Host path 校验** | 黑名单策略,`/` 未拦截 | 白名单策略,默认拒绝 | **P1** | +| **数据驻留** | 无控制 | GDPR / 等保要求的数据隔离 | **P2** | + +### 2.2 多租户 + +| 维度 | 现状 | toB 要求 | 优先级 | +|------|------|---------|--------| +| **租户隔离** | 无租户概念 | BoxSpec/Profile 绑定 tenant_id | **P0** | +| **RBAC** | 仅 token 认证 | admin/operator/viewer 角色权限 | **P0** | +| **资源配额** | 单一 workspace quota | 每租户 CPU 时间/内存/并发/执行次数配额 | **P1** | +| **Session 隔离** | 所有 session 共享 dict | 按租户分区,互不可见 | **P1** | + +### 2.3 可靠性 + +| 维度 | 现状 | toB 要求 | 优先级 | +|------|------|---------|--------| +| **连接恢复** | 已实现:20s 心跳 + `_reconnect_loop` 指数退避 | 已满足基本要求 | 已有 | +| **Session 清理** | 机会性(仅新建时触发) | 定时清理 + 独立 reaper | **P1** | +| **水平扩展** | 单 Box Runtime 实例 | 多实例负载均衡(按 tenant 路由) | **P1** | +| **优雅降级** | 已有(_available=False) | 已满足基本要求 | 已有 | +| **Backend 自愈** | 已实现:`get_status` 时若 backend 不可用会重新选择 | 已满足基本要求 | 已有 | + +### 2.4 可观测性 + +| 维度 | 现状 | toB 要求 | 优先级 | +|------|------|---------|--------| +| **监控指标** | 无 Prometheus metrics | session 数/执行延迟/资源用量/错误率 | **P1** | +| **结构化日志** | Python logging, 无结构化 | JSON 格式日志,含 trace_id/tenant_id | **P1** | +| **前端面板** | 监控页接入 `/api/v1/box/status`(backend 名 + 活跃 session 数);`sessions` / `errors` 仍未接入 | 完整状态面板 + 历史错误/审计列表 | **P2** | + +--- + +## 3. SaaS 部署架构建议 + +### 3.1 方案 A: 共享 Box Runtime Pool (快速上线) + +``` +LangBot Instance ──> Box Runtime (共享) + ├─ tenant_id 标签隔离 + ├─ Redis 配额计数器 + └─ Container labels: langbot.tenant_id=xxx +``` + +- **优点**: 改动最小,加 tenant_id 到 BoxSpec/labels 即可 +- **缺点**: 容器引擎共享,安全隔离弱 + +### 3.2 方案 B: 每租户 K8s Namespace + gVisor (推荐中期) + +``` +LangBot ──> K8s API + ├─ namespace: tenant-xxx + │ ├─ RuntimeClass: gVisor (runsc) + │ ├─ ResourceQuota + │ └─ NetworkPolicy + └─ namespace: tenant-yyy + └─ ... +``` + +- **优点**: 强隔离(namespace + gVisor),原生 K8s 配额 +- **缺点**: 需要重写 backend 为 K8s Job,部署复杂度高 + +### 3.3 方案 C: K8s Job 直接编排 (长期) + +``` +LangBot ──> K8s Job per execution + ├─ 每次执行创建 Job + ├─ Pod Security Standards + ├─ 自动调度和资源分配 + └─ Job TTL Controller 自动清理 +``` + +- **优点**: 最强隔离,天然水平扩展 +- **缺点**: 冷启动延迟,架构重写 + +**推荐演进路径**: A → B → C + +--- + +## 4. 配额体系建议 + +### 三层配额 + +| 层 | 实现 | 作用 | +|----|------|------| +| **内核层** | Docker `--cpus`/`--memory`/`--storage-opt` | 硬性资源上限,不可绕过 | +| **应用层** | Redis 原子计数器 | 并发 session 数/执行次数/CPU 时间预算 | +| **计费层** | 月度聚合 | 按租户计费(session-hours/execution-count) | + +### Profile 与套餐映射 + +| 套餐 | Profile | locked 字段 | 配额 | +|------|---------|------------|------| +| Free | `offline_readonly` | network, host_path_mode, rootfs | 10 exec/天, 0.5 CPU, 256MB | +| Pro | `default` | (无) | 100 exec/天, 1 CPU, 512MB | +| Enterprise | `network_extended` | (按需) | 无限, 2 CPU, 1GB, 自定义镜像 | + +### TOCTOU 配额修复 + +当前 `_enforce_workspace_quota` 的 TOCTOU 问题可通过两种方式解决: + +1. **预留式配额** (应用层): Redis `INCRBY` 预扣额度 → 执行 → 成功则扣减,失败则回滚 +2. **内核级限制** (Docker): `--storage-opt size=500m` 直接限制容器可写层大小 + +--- + +## 5. 优先实施路线 + +### Phase 1 (2-4 周): 安全基线 + +- [ ] WS relay 加 token 认证 +- [ ] 接入或删除 policy.py +- [x] ~~Box 加重连和心跳~~(已完成,见 [box-issues.md 已解决](./box-issues.md)) +- [ ] 审计日志持久化(至少写文件/数据库) +- [ ] `security.py` 加 `/` 拦截,考虑白名单 +- [ ] INIT 与 backend 初始化顺序整理(避免 backend 在配置到达前实例化) + +### Phase 2 (4-8 周): 多租户基础 + +- [ ] BoxSpec 加 `tenant_id` 字段 +- [ ] 容器 labels 加 tenant 标识 +- [ ] Redis 配额计数器(并发/执行次数/时间) +- [ ] RBAC 基础框架 +- [ ] 定时 session reaper + +### Phase 3 (8-16 周): 生产就绪 + +- [ ] Prometheus metrics exporter +- [ ] 前端 Box 状态面板 +- [ ] K8s backend 支持 (方案 B) +- [ ] 结构化日志 (JSON, trace_id) +- [ ] 水平扩展支持 diff --git a/docs/review/box-vs-plugin-runtime.md b/docs/review/box-vs-plugin-runtime.md new file mode 100644 index 000000000..3e3aa1f82 --- /dev/null +++ b/docs/review/box-vs-plugin-runtime.md @@ -0,0 +1,222 @@ +# Box Runtime vs Plugin Runtime: 连接架构对比 + +> 更新日期: 2026-06-02 +> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。 +> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) + +--- + +## 1. 总体差异 + +| 维度 | Plugin Runtime | Box Runtime | +|------|---------------|-------------| +| **继承关系** | `PluginRuntimeConnector(ManagedRuntimeConnector)` | `BoxRuntimeConnector`(独立类) | +| **传输分支** | 3 条 (Docker/WS, Win32/subprocess+WS, Unix/stdio) | 3 条 (本地 stdio, Win32/subprocess+WS, 远程 WS) | +| **心跳** | 20s ping loop | 20s ping loop(`_heartbeat_loop`) | +| **重连** | WS 模式: sleep 3s → re-initialize | 由 BoxService `_reconnect_loop` 处理,指数退避 | +| **Handler 类型** | `RuntimeConnectionHandler` (1132 行, 25+ action) | 基础 `Handler` + `BoxServerHandler`(SDK 端 25 action) | +| **Client 抽象** | Handler 即 API | 独立 `ActionRPCBoxClient` 封装 Handler | +| **启用/禁用** | `is_enable_plugin` 开关 | 无开关(可用/不可用由初始化结果决定) | +| **初始化失败** | 异常上抛 | 静默降级 `_available=False` | +| **Shutdown** | 直接杀进程 | RPC SHUTDOWN → 清理容器 → 再杀进程 | + +--- + +## 2. 传输决策 + +### Plugin: 3-路决策 + +```python +# pkg/plugin/connector.py:106-165 +if get_platform() == 'docker' or use_websocket_to_connect_plugin_runtime(): + # Docker/WS → ws://langbot_plugin_runtime:5400/control/ws +elif get_platform() == 'win32': + # Windows → 起子进程(无 pipe) + ws://localhost:5400/control/ws +else: + # Unix/Mac → StdioClientController(python -m langbot_plugin.cli rt -s) +``` + +### Box: 3-路决策 + +```python +# pkg/box/connector.py +if self._uses_websocket(): + if platform.get_platform() == 'win32' and not self.configured_runtime_url: + await self._start_subprocess_then_ws() # subprocess + ws://localhost:5410/rpc/ws + else: + await self._connect_remote_ws() # ws://{host}:5410/rpc/ws +else: + await self._start_local_stdio() # StdioClientController +``` + +> 历史:2026-04-16 版本本文档曾把 Box 描述为 2 路决策(缺 Windows 分支)。现已对齐 Plugin 的 3 路设计。 + +### 决策矩阵 + +| 环境 | Plugin | Box | +|------|--------|-----| +| Docker | WS → `:5400` | WS → `:5410/rpc/ws` | +| `--standalone-box` | N/A | WS → `localhost:5410/rpc/ws` | +| Windows 非 Docker | subprocess + WS (`:5400`) | subprocess + WS (`localhost:5410/rpc/ws`) | +| Unix/Mac 非 Docker | stdio | stdio | +| 手动配置 URL | 通过配置项 | WS → 用户配置的 URL | + +--- + +## 3. 连接建立 + +### 同步模式差异 + +**Plugin**: `new_connection_callback` 内直接 ping + await handler_task,`initialize()` 通过 `create_task()` 异步启动,不阻塞等待连接。 + +**Box**: 使用 `asyncio.Event` + `wait_for(timeout=30s)` 模式,`initialize()` 同步等待连接成功或超时。 + +### Box stdio 路径 + +``` +connector._start_local_stdio() + ├─ connected = asyncio.Event() + ├─ ctrl = StdioClientController(python, ['-m', 'langbot_plugin.cli.__init__', 'box', '-s', '--ws-control-port', N]) + ├─ _ctrl_task = create_task(ctrl.run(callback)) + │ callback: + │ handler = Handler(connection) ← 基础 Handler, 无 disconnect_callback + │ client.set_handler(handler) + │ _handler_task = create_task(handler.run()) + │ call_action(PING, {}) ← 握手, timeout=15s + │ connected.set() ← 通知外层 + │ await _handler_task ← 阻塞直到断开 + └─ await wait_for(connected.wait(), 30s) ← 同步等待 +``` + +### Plugin stdio 路径 + +``` +connector.initialize() + ├─ ctrl = StdioClientController(python, ['-m', 'langbot_plugin.cli', 'rt', '-s']) + ├─ task = ctrl.run(callback) + │ callback: + │ disconnect_callback: + │ [WS] → runtime_disconnect_callback → 重连 + │ [stdio] → 仅日志, 不重连 + │ handler = RuntimeConnectionHandler(conn, disconnect_cb, ap) + │ create_task(handler.run()) + │ handler.ping() ← 握手, timeout=10s + │ await handler_task ← 阻塞直到断开 + ├─ create_task(heartbeat_loop()) ← 20s ping loop + └─ create_task(task) ← 不等待连接 +``` + +--- + +## 4. 心跳与重连 + +### 心跳 + +| 维度 | Plugin | Box | +|------|--------|-----| +| 有心跳? | 是 | 是(`connector.py` `_heartbeat_loop`) | +| 间隔 | 20s | 20s | +| 失败处理 | 仅 DEBUG 日志,不触发重连 | 仅 DEBUG 日志,依赖 connection close 触发重连 | +| 生命周期 | 整个应用生命周期 | 连接建立后启动;`dispose()` 时 cancel | + +### 重连 + +| 维度 | Plugin | Box | +|------|--------|-----| +| Docker/WS 断开 | `runtime_disconnect_callback` → sleep 3s → re-initialize | `runtime_disconnect_callback` → `BoxService._reconnect_loop()`(指数退避) | +| WS 连接失败 | 同上 | 同上;初次失败时 `_available=False`,重连成功后恢复 | +| stdio 断开 | 仅日志,不重连 | 接同样回调;stdio 重连需重新 fork 子进程 | +| 重连退避 | 固定 3s,无 backoff | 指数退避 | + +> 历史:2026-04-16 版本本文档曾把心跳与重连标记为 Box 缺失。这两项已在 commit `2dfd9d5d` / `c6882cf` / `5029d9c` 等修复(详见 [box-issues.md 已解决](./box-issues.md))。 + +--- + +## 5. 共享 IO 层 + +两者复用同一套 SDK IO 基础设施: + +``` +Handler ← ABC (runtime/io/handler.py) + ├── RuntimeConnectionHandler (Plugin 用, LangBot 侧) + ├── ControlConnectionHandler (Plugin 用, SDK 侧) + ├── BoxServerHandler (Box 用, SDK 侧) + └── 匿名 Handler 实例 (Box 用, LangBot 侧) + +Connection ← ABC + ├── StdioConnection (stdio: 16KB chunks, 应用层分帧协议) + └── WebSocketConnection (WS: 64KB chunks, 原生 WS 分帧) + +Controller ← ABC + ├── StdioClientController (fork 子进程, pipe stdin/stdout) + ├── StdioServerController (接管当前进程 stdin/stdout) + ├── WebSocketClientController (连接 WS 服务端) + └── WebSocketServerController (监听 WS 端口) +``` + +共享的核心机制: +- `call_action()` / `call_action_generator()` — RPC 调用/流式调用 +- `ActionRequest` / `ActionResponse` — 请求/响应协议 +- `seq_id` 关联 — 并发请求复用单连接 +- `CommonAction.PING` — 两者都用于初始握手 +- 文件传输 (`send_file`) — Plugin 用,Box 不用 + +--- + +## 6. 端口方案 + +| 服务 | Plugin | Box | +|------|--------|-----| +| Action RPC (stdio) | stdin/stdout | stdin/stdout | +| Action RPC (WS) | `:5400` | `:5410/rpc/ws` | +| 辅助服务 | debug WS `:5401` | managed process WS relay `:5410/v1/sessions/{id}/managed-process/ws` | + +**Box 特点**: 单端口 aiohttp 服务(默认 5410),通过路径区分 Action RPC 和 managed process relay。即使在 stdio 模式,也在 `:5410` 启动 aiohttp 用于 managed process attach。Plugin 在 stdio 模式不开额外端口。 + +--- + +## 7. 销毁对比 + +### Plugin + +```python +dispose(): + if stdio: ctrl.process.terminate() + _dispose_subprocess() # Windows 子进程 + heartbeat_task.cancel() +``` + +### Box + +```python +connector.dispose(): + _handler_task.cancel() + _ctrl_task.cancel() + _subprocess.terminate() + +service.dispose(): + connector.dispose() + loop.create_task(client.shutdown()) # RPC SHUTDOWN → 清理所有容器 +``` + +Box 的 RPC SHUTDOWN 确保容器被正确停止,不会成为孤儿。Plugin 直接杀进程。 + +--- + +## 8. 改进建议 + +### P0 + +1. **两者都加 WS 认证**: 至少 token 认证(INIT 时下发,连接时校验) + +### P1 + +2. **考虑 Box 继承 ManagedRuntimeConnector**: 复用 `_start_runtime_subprocess` / `_wait_until_ready` / `_dispose_subprocess`,减少重复代码 +3. **Plugin 重连加退避**: 固定 3s 无 backoff 可能造成日志洪水,建议向 Box 的指数退避看齐 +4. **统一连接管理模式**: Event-based (Box) vs direct-await (Plugin),考虑收敛为一种 + +### 已完成(自上一轮) + +- ~~Box 加重连~~(commit `2dfd9d5d`) +- ~~Box 加心跳~~(20s loop 与 Plugin 一致) +- ~~Box 加 Windows 支持~~(commit `120817a` / `fafb7a4`) diff --git a/docs/review/mcp-resources-pr-2215-review.md b/docs/review/mcp-resources-pr-2215-review.md new file mode 100644 index 000000000..8e57fc5fa --- /dev/null +++ b/docs/review/mcp-resources-pr-2215-review.md @@ -0,0 +1,196 @@ +# MCP Resources PR #2215 Review + +> 更新日期: 2026-06-29 +> 分支: `mcp_resources` +> PR: langbot-app/LangBot#2215 +> 主题: MCP Resources 在 LangBot 中的产品价值、AgentRunner 集成方式与后续架构方向 + +## 结论 + +PR #2215 对 LangBot 有明确价值:它补齐了 MCP 协议中 Resources 这一重要能力,让 MCP server 不再只暴露 tools,也可以暴露文档、代码片段、配置、日志、图片等上下文资源。管理端可以发现和预览资源,Agent 也可以通过当前实现按需列出和读取资源。 + +但当前 AgentRunner 层的接入方式更接近一个可用的第一阶段方案,而不是最终架构。现在 MCP Resources 被包装成两个 synthetic tools: + +- `langbot_mcp_list_resources` +- `langbot_mcp_read_resource` + +这让模型可以通过 function calling 主动探索资源,落地成本低,也复用了已有 `ToolManager` / `LocalAgentRunner` 的工具调用链路。不过从 MCP 规范和主流实现来看,Resources 更适合作为一种一等上下文来源,而不是长期隐藏在工具列表里。 + +建议保留当前 synthetic tools 作为探索能力,同时把后续主线设计调整为:MCP Resources 是 pipeline / conversation / message 级别可选择、可固定、可审计的上下文输入。 + +## 当前实现判断 + +当前 AgentRunner 集成路径如下: + +```text +Pipeline 绑定 MCP server + -> query.variables['_pipeline_bound_mcp_servers'] + -> Preproc 为 local-agent 加载工具 + -> ToolManager.get_all_tools() + -> MCPLoader 注入 synthetic resource tools + -> LocalAgentRunner 将工具 schema 传给模型 + -> 模型发起 list/read tool call + -> ToolManager.execute_func_call() + -> MCPLoader 调 MCP session.list_resources/read_resource + -> tool result 回灌给模型 +``` + +这个路径的优点是: + +- 复用现有工具调用机制,改动范围小。 +- Agent 可以按需探索资源,不需要每轮预先读取所有资源。 +- 可以沿用 pipeline 绑定的 MCP server 范围,避免越权读取未绑定 server。 +- 对已有 MCP tools 行为影响较小。 + +主要问题是: + +- Resources 在语义上被降级成 tools,和 MCP 规范里的 resource primitive 不完全一致。 +- 模型必须先理解并主动调用 `list/read`,资源不会自然成为上下文。 +- pipeline 不能配置“默认携带某些资源”或“本轮附加某些资源”。 +- UI 资源 tab 目前是管理端预览能力,和 Agent 上下文选择没有打通。 +- 对 blob、图片、大文件、结构化资源的处理还比较粗糙。 +- 缺少 resource templates、订阅更新、缓存、chunk、token budget、trace 与审计策略。 + +## 主流项目做法 + +### MCP 官方规范 + +MCP Resources 是 server 暴露上下文数据的协议能力。规范没有要求 resources 必须以 tool call 形式给模型使用,而是把如何选择、过滤、读取和纳入上下文交给 Host application。 + +这意味着比较正统的集成方式是:LangBot 作为 Host,在 pipeline、会话或消息层决定哪些 resources 进入模型上下文。 + +参考: https://modelcontextprotocol.io/specification/2025-06-18/server/resources + +### VS Code Copilot + +VS Code 把 MCP Resources 做成 chat context 的一部分。用户可以通过 `Add Context > MCP Resources` 或命令浏览 MCP resources,并把选中的资源附加到一次 chat request。 + +这是目前最值得 LangBot 参考的产品形态:资源不是模型工具,而是用户和 Host 可控的上下文附件。 + +参考: https://code.visualstudio.com/docs/agent-customization/mcp-servers + +### Anthropic SDK + +Anthropic 的 client-side MCP helpers 提供资源读取和转换能力,例如把 MCP resource 转为 Claude message content 或 file。也就是说,应用先读取 resource,再显式放进模型消息。 + +这同样是 application-owned context injection,而不是把 resource 伪装成模型工具。 + +参考: https://platform.claude.com/docs/en/agents-and-tools/mcp-connector + +### LangChain MCP Adapters + +LangChain 把 MCP Resources 更像 data loader / document input 来处理,可以把资源加载成 `Blob`,再进入 LangChain 的文档、检索或上下文处理链路。 + +这说明 Resources 很适合作为知识源、文档源或上下文源,而不只是即时工具调用。 + +参考: https://docs.langchain.com/oss/python/langchain/mcp + +### OpenAI Agents SDK + +OpenAI Agents SDK 主路径仍偏向 MCP tools,但底层 MCP server API 已经有 `list_resources`、`list_resource_templates`、`read_resource` 等能力。当前形态说明 resources 是 client 能力,但并未默认变成 agent-visible tools。 + +参考: https://openai.github.io/openai-agents-python/mcp/ + +### Cline + +Cline 会拉取 MCP tools、resources、resourceTemplates、prompts,并通过类似 `access_mcp_resource` 的内置访问方式让模型读取资源。这个方向和 LangBot 当前 synthetic tools 比较接近。 + +这种模式适合让 Agent 自主探索,但更像 Host 自定义的模型访问协议,不应成为唯一集成路径。 + +参考: https://github.com/cline/cline/blob/main/src/services/mcp/McpHub.ts + +## 建议架构方向 + +### 1. 保留探索型工具 + +保留当前两个 synthetic tools: + +- `langbot_mcp_list_resources` +- `langbot_mcp_read_resource` + +它们适合处理“用户没有显式选择资源,但 Agent 判断需要探索 MCP server 上下文”的场景。后续可以优化工具描述、返回格式、资源大小限制和错误信息。 + +### 2. 增加一等 Resource Context + +新增一个 Host 层资源上下文概念,例如: + +```text +PipelineResourceBinding +ConversationResourceAttachment +MessageResourceAttachment +``` + +Preproc 或独立的 `ResourceContextProvider` 在模型调用前读取这些资源,按 MIME 类型、大小、token budget 转为模型可消费的上下文。 + +### 3. 打通 UI 与 Agent 上下文 + +当前 MCP 详情页的 Resources tab 可以继续作为资源发现和预览入口。建议增加操作: + +- 添加到本轮上下文 +- 固定到当前 pipeline +- 固定到当前 bot / conversation +- 查看资源读取历史和错误 + +这样 UI 资源管理能力才能真正影响 Agent 行为。 + +### 4. 支持 resource templates + +MCP resource templates 允许 server 暴露参数化资源,例如: + +```text +repo://{owner}/{repo}/file/{path} +log://{service}/{date} +``` + +LangBot 后续应支持模板发现、参数填写、实例化和绑定。否则只能使用静态 resources,覆盖面会受限。 + +### 5. 增加资源处理策略 + +建议补齐: + +- 文本资源 token budget 与截断策略。 +- 大文件 chunk 与摘要策略。 +- 图片/blob 的模型能力判断与 fallback。 +- MIME 类型白名单与安全限制。 +- 缓存与过期策略。 +- `resources/listChanged` 或订阅更新。 +- resource read trace,便于审计 Agent 读取了什么上下文。 + +## 推荐落地顺序 + +### Phase 1: 完成当前 PR 可用性 + +- 保留 synthetic tools。 +- 明确文档说明当前 Agent 集成是 tool-mediated。 +- 完善资源工具描述,降低模型误用概率。 +- 给 read/list 增加大小限制和更清晰的 MIME 处理。 +- 前端 Resources tab 与 Tools tab 分离,保持管理端清晰。 + +### Phase 2: 做 Host-owned context attachments + +- 在 pipeline 或 conversation 层新增 resource attachment 配置。 +- Preproc 读取已绑定 resources,注入模型上下文。 +- UI 支持“添加到上下文 / 固定到 pipeline”。 +- 记录每轮实际注入的 resource URI 和 token 消耗。 + +### Phase 3: 做完整 MCP Resources 能力 + +- 支持 resource templates。 +- 支持资源订阅更新。 +- 支持 chunk、summary、RAG 化接入。 +- 为 DifyAgentRunner、LocalAgentRunner 等不同 runner 定义统一资源上下文接口。 + +## 最终建议 + +PR #2215 可以作为 MCP Resources 的第一阶段实现继续推进。它让 LangBot 快速拥有“资源发现、预览、按需读取”的闭环,也给 Agent 探索资源提供了可运行路径。 + +但在正式设计上,不建议把 “Resources == Tools” 固化为长期抽象。LangBot 更应该把 MCP Resources 定位为上下文来源,与 tools、prompts、knowledge base 并列: + +```text +Tools -> Agent 可以执行的动作 +Resources -> Host/用户/Agent 可以选择的上下文数据 +Prompts -> 可复用的任务模板 +Knowledge -> 可检索、可索引的长期知识 +``` + +这样既尊重 MCP 协议语义,也能让 LangBot 在 Agent 工作流、企业知识接入和多 MCP server 管理上走得更稳。 diff --git a/examples/http-bot/README.md b/examples/http-bot/README.md new file mode 100644 index 000000000..b04387d78 --- /dev/null +++ b/examples/http-bot/README.md @@ -0,0 +1,75 @@ +# HTTP Bot Adapter — Reference Clients + +> English | [中文](./README.zh.md) + +Minimal, dependency-light clients for the LangBot **HTTP Bot** platform adapter. +They show the whole loop: signing a request, pushing a message, and receiving +multi-part replies on a callback endpoint. + +Full guide: [docs.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot). +Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json). + +## Files + +| File | What it is | +|---|---| +| `playground.py` | **Interactive browser debug console** — a single-file web app you open in a browser to chat with a running `http_bot` bot and watch signing / 202 / callbacks live. Zero extra deps. | +| `client.py` | Python client + Flask callback receiver (`pip install flask requests`). | +| `client.ts` | TypeScript/Node 18+ client + callback receiver, **zero deps** (`npx tsx client.ts`). | + +All three implement the identical HMAC-SHA256 scheme +(`sha256=hex(HMAC(secret, "{timestamp}." + body))`) — verified byte-for-byte +against the adapter. + +## Interactive playground (recommended first run) + +A self-contained web console: type a message in your browser, it is signed and +POSTed to a **running** `http_bot` bot, and the bot's replies stream back into +the page — with a debug panel showing the signature, the `202` ack, and each +callback's `sequence` / signature-verification. + +```bash +# From the LangBot repo root, with the backend already running: +PUBLIC_IP= ./.venv/bin/python examples/http-bot/playground.py +# then open http://:8920/ +``` + +On startup it reads the LangBot API key + the `http_bot` bot from +`data/langbot.db`, and configures that bot (inbound/outbound secret + +`callback_url`) to point back at itself via the LangBot API — the bot reloads +live, no restart needed. Requirements: an enabled `http_bot` bot bound to a +working pipeline, and port `8920` reachable from your browser. + +Env knobs: `PUBLIC_IP` (default `127.0.0.1`), `PLAYGROUND_PORT` (default `8920`). + +## Headless clients + +```bash +# Python — Terminal 1: callback receiver (your callback_url target) +python client.py serve --port 8900 --secret SHARED_SECRET + +# Python — Terminal 2: push a message +python client.py push --url https://your-langbot/bots/ \ + --secret SHARED_SECRET --session ticket-1 --text "hello" + +# blocking sync mode +python client.py sync --url https://your-langbot/bots/ \ + --secret SHARED_SECRET --session ticket-1 --text "hello" + +# reset a session +python client.py reset --url https://your-langbot/bots/ \ + --secret SHARED_SECRET --session ticket-1 +``` + +```bash +# TypeScript (Node 18+) +npx tsx client.ts serve 8900 SHARED_SECRET +npx tsx client.ts push https://your-langbot/bots/ SHARED_SECRET ticket-1 "hello" +``` + +When the bot replies, the receiver prints each part with its `sequence` and an +`[FINAL]` marker on the last one — that's the 1→M multi-reply model in action. + +> The bot's `callback_url` must be reachable from LangBot. For local testing, +> expose your receiver with a tunnel (cloudflared / ngrok) and set that URL in +> the bot config. diff --git a/examples/http-bot/README.zh.md b/examples/http-bot/README.zh.md new file mode 100644 index 000000000..1baf81272 --- /dev/null +++ b/examples/http-bot/README.zh.md @@ -0,0 +1,71 @@ +# HTTP Bot 适配器 —— 参考客户端 + +> [English](./README.md) | 中文 + +面向 LangBot **HTTP Bot** 平台适配器的极简、低依赖客户端示例。 +它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收 +1→M 的多段回复。 + +完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。 +机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。 + +## 文件清单 + +| 文件 | 是什么 | +|---|---| +| `playground.py` | **浏览器交互式调试台** —— 单文件 Web 应用,在浏览器里和一个运行中的 `http_bot` bot 对话,实时观察签名 / 202 / 回调。零额外依赖。 | +| `client.py` | Python 客户端 + Flask 回调接收端(`pip install flask requests`)。 | +| `client.ts` | TypeScript/Node 18+ 客户端 + 回调接收端,**零依赖**(`npx tsx client.ts`)。 | + +三者实现完全一致的 HMAC-SHA256 签名方案 +(`sha256=hex(HMAC(secret, "{timestamp}." + body))`)—— 已与适配器逐字节比对验证。 + +## 交互式 playground(推荐先跑这个) + +一个自包含的 Web 控制台:在浏览器里输入消息,它会被签名并 POST 给一个 +**运行中**的 `http_bot` bot,bot 的回复会流式回到页面上 —— 调试面板会显示 +签名、`202` 确认,以及每条回调的 `sequence` / 签名验证结果。 + +```bash +# 在 LangBot 仓库根目录、后端已启动的前提下: +PUBLIC_IP=<你的主机IP> ./.venv/bin/python examples/http-bot/playground.py +# 然后打开 http://<你的主机IP>:8920/ +``` + +启动时它会从 `data/langbot.db` 读取 LangBot API key 和 `http_bot` bot, +并通过 LangBot API 把该 bot 配好(入站/出站密钥 + `callback_url`)指回自己 —— +bot 会热加载,无需重启。前提:有一个已启用、绑定了可用 pipeline 的 +`http_bot` bot,且端口 `8920` 能从你的浏览器访问到。 + +可调环境变量:`PUBLIC_IP`(默认 `127.0.0.1`)、`PLAYGROUND_PORT`(默认 `8920`)。 + +## 无头客户端 + +```bash +# Python —— 终端 1:回调接收端(你的 callback_url 指向它) +python client.py serve --port 8900 --secret SHARED_SECRET + +# Python —— 终端 2:推送一条消息 +python client.py push --url https://your-langbot/bots/ \ + --secret SHARED_SECRET --session ticket-1 --text "hello" + +# 阻塞式同步模式 +python client.py sync --url https://your-langbot/bots/ \ + --secret SHARED_SECRET --session ticket-1 --text "hello" + +# 重置一个会话 +python client.py reset --url https://your-langbot/bots/ \ + --secret SHARED_SECRET --session ticket-1 +``` + +```bash +# TypeScript(Node 18+) +npx tsx client.ts serve 8900 SHARED_SECRET +npx tsx client.ts push https://your-langbot/bots/ SHARED_SECRET ticket-1 "hello" +``` + +当 bot 回复时,接收端会逐条打印,带上各自的 `sequence`,并在最后一条标记 +`[FINAL]` —— 这就是 1→M 多段回复模型的实际效果。 + +> bot 的 `callback_url` 必须能从 LangBot 访问到。本地测试时,可用隧道 +> (cloudflared / ngrok)把你的接收端暴露出去,并把那个 URL 填进 bot 配置。 diff --git a/examples/http-bot/client.py b/examples/http-bot/client.py new file mode 100644 index 000000000..cbf9eff43 --- /dev/null +++ b/examples/http-bot/client.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""LangBot HTTP Bot adapter — reference client (Python). + +Two things in one file: + +1. ``push()`` / ``push_sync()`` — send a message into a LangBot ``http_bot`` bot. +2. A tiny Flask callback receiver that verifies signatures and prints replies, + so you can watch N->1 aggregation and 1->M multi-reply working live. + +Usage +----- + pip install flask requests + + # Terminal 1 — start the callback receiver (this is your callback_url): + python client.py serve --port 8900 --secret SHARED_SECRET + + # Terminal 2 — push a message (async; reply lands on the receiver): + python client.py push \ + --url https://your-langbot/bots/ \ + --secret SHARED_SECRET \ + --session ticket-10293 \ + --text "Export keeps failing on the dashboard." + + # Or push and block for the collapsed reply (sync convenience mode): + python client.py sync --url https://your-langbot/bots/ \ + --secret SHARED_SECRET --session ticket-10293 --text "hi" + +The signing scheme is HMAC-SHA256 over ``"{timestamp}." + raw_body``; see +``sign()`` below — it is intentionally tiny and easy to port. +""" + +from __future__ import annotations + +import argparse +import hashlib +import hmac +import json +import sys +import time +import uuid + +HEADER_TIMESTAMP = 'X-LB-Timestamp' +HEADER_SIGNATURE = 'X-LB-Signature' +HEADER_IDEMPOTENCY = 'X-LB-Idempotency-Key' +REPLAY_WINDOW = 300 + + +def sign(secret: str, body: bytes, timestamp: int | None = None) -> tuple[str, str]: + """Return (timestamp, signature) for *body*.""" + ts = str(timestamp if timestamp is not None else int(time.time())) + mac = hmac.new(secret.encode(), f'{ts}.'.encode() + body, hashlib.sha256) + return ts, 'sha256=' + mac.hexdigest() + + +def verify(secret: str, body: bytes, timestamp: str | None, signature: str | None) -> bool: + """Verify an inbound signature (used by the callback receiver).""" + if not timestamp or not signature: + return False + try: + if abs(int(time.time()) - int(float(timestamp))) > REPLAY_WINDOW: + return False + except ValueError: + return False + _, expected = sign(secret, body, int(float(timestamp))) + return hmac.compare_digest(expected, signature) + + +def _post(url: str, secret: str, payload: dict, idempotency: bool = True): + import requests + + body = json.dumps(payload, ensure_ascii=False).encode() + ts, sig = sign(secret, body) + headers = { + 'Content-Type': 'application/json', + HEADER_TIMESTAMP: ts, + HEADER_SIGNATURE: sig, + } + if idempotency: + headers[HEADER_IDEMPOTENCY] = uuid.uuid4().hex + resp = requests.post(url, data=body, headers=headers, timeout=30) + print(f'-> {resp.status_code} {resp.text}') + return resp + + +def push(url: str, secret: str, session: str, text: str, session_type: str = 'person'): + """Fire-and-collect: returns 202 immediately; reply arrives on your callback.""" + payload = { + 'session_id': session, + 'session_type': session_type, + 'message': [{'type': 'Plain', 'text': text}], + } + return _post(url.rstrip('/'), secret, payload) + + +def push_sync(url: str, secret: str, session: str, text: str, session_type: str = 'person'): + """Blocking convenience: POST to /sync and get the collapsed reply back.""" + payload = { + 'session_id': session, + 'session_type': session_type, + 'message': [{'type': 'Plain', 'text': text}], + } + resp = _post(url.rstrip('/') + '/sync', secret, payload, idempotency=False) + return resp + + +def reset(url: str, secret: str, session: str, session_type: str = 'person'): + """Reset a session's conversation (next message starts fresh).""" + payload = {'session_id': session, 'session_type': session_type} + return _post(url.rstrip('/') + '/reset', secret, payload, idempotency=False) + + +def serve(port: int, secret: str): + """Run a callback receiver that verifies signatures and prints replies.""" + from flask import Flask, request + + app = Flask(__name__) + + @app.route('/', methods=['POST']) + def recv(): + raw = request.get_data() + ok = verify(secret, raw, request.headers.get(HEADER_TIMESTAMP), request.headers.get(HEADER_SIGNATURE)) + if not ok: + print('!! signature verification FAILED — rejecting') + return {'error': 'bad signature'}, 401 + data = json.loads(raw) + text_parts = [c.get('text', '') for c in data.get('message', []) if c.get('type') == 'Plain'] + marker = 'FINAL' if data.get('is_final') else 'part ' + print( + f'[{marker}] session={data["session_id"]} seq={data["sequence"]} ' + f'reply_to={data.get("reply_to")}: {" ".join(text_parts)}' + ) + return {'ok': True} + + print(f'callback receiver listening on http://0.0.0.0:{port}/ (Ctrl-C to stop)') + app.run(host='0.0.0.0', port=port) + + +def main(argv=None): + p = argparse.ArgumentParser(description='LangBot HTTP Bot reference client') + sub = p.add_subparsers(dest='cmd', required=True) + + sp = sub.add_parser('serve', help='run the callback receiver') + sp.add_argument('--port', type=int, default=8900) + sp.add_argument('--secret', required=True) + + for name in ('push', 'sync', 'reset'): + c = sub.add_parser(name) + c.add_argument('--url', required=True, help='https://host/bots/') + c.add_argument('--secret', required=True) + c.add_argument('--session', required=True) + c.add_argument('--session-type', default='person', choices=['person', 'group']) + if name != 'reset': + c.add_argument('--text', required=True) + + args = p.parse_args(argv) + if args.cmd == 'serve': + serve(args.port, args.secret) + elif args.cmd == 'push': + push(args.url, args.secret, args.session, args.text, args.session_type) + elif args.cmd == 'sync': + push_sync(args.url, args.secret, args.session, args.text, args.session_type) + elif args.cmd == 'reset': + reset(args.url, args.secret, args.session, args.session_type) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/examples/http-bot/client.ts b/examples/http-bot/client.ts new file mode 100644 index 000000000..ec121823c --- /dev/null +++ b/examples/http-bot/client.ts @@ -0,0 +1,123 @@ +/** + * LangBot HTTP Bot adapter — reference client (TypeScript / Node 18+). + * + * Zero runtime dependencies (uses global `fetch`, `crypto`, and `http`). + * + * - `push()` : fire-and-collect; reply lands on your callback URL. + * - `pushSync()` : POST /sync and await the collapsed reply. + * - `reset()` : reset a session's conversation. + * - `startReceiver()` : a callback server that verifies signatures and logs + * replies, so you can watch N->1 and 1->M live. + * + * Run the demos: + * npx tsx client.ts serve 8900 SHARED_SECRET + * npx tsx client.ts push https://host/bots/ SHARED_SECRET ticket-1 "hello" + * npx tsx client.ts sync https://host/bots/ SHARED_SECRET ticket-1 "hello" + * npx tsx client.ts reset https://host/bots/ SHARED_SECRET ticket-1 + */ + +import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; +import { createServer } from 'node:http'; + +const HEADER_TIMESTAMP = 'X-LB-Timestamp'; +const HEADER_SIGNATURE = 'X-LB-Signature'; +const HEADER_IDEMPOTENCY = 'X-LB-Idempotency-Key'; +const REPLAY_WINDOW = 300; + +/** Compute the `sha256=` signature over `"{ts}." + body`. */ +export function sign(secret: string, body: Buffer | string, timestamp?: number): [string, string] { + const ts = String(timestamp ?? Math.floor(Date.now() / 1000)); + const buf = typeof body === 'string' ? Buffer.from(body) : body; + const mac = createHmac('sha256', secret).update(Buffer.concat([Buffer.from(`${ts}.`), buf])).digest('hex'); + return [ts, `sha256=${mac}`]; +} + +/** Verify an inbound signature (used by the callback receiver). */ +export function verify(secret: string, body: Buffer, timestamp?: string, signature?: string): boolean { + if (!timestamp || !signature) return false; + if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > REPLAY_WINDOW) return false; + const [, expected] = sign(secret, body, Number(timestamp)); + const a = Buffer.from(expected); + const b = Buffer.from(signature); + return a.length === b.length && timingSafeEqual(a, b); +} + +interface Segment { type: string; text?: string; url?: string; [k: string]: unknown } + +async function post(url: string, secret: string, payload: object, idempotency = true) { + const body = Buffer.from(JSON.stringify(payload)); + const [ts, sig] = sign(secret, body); + const headers: Record = { + 'Content-Type': 'application/json', + [HEADER_TIMESTAMP]: ts, + [HEADER_SIGNATURE]: sig, + }; + if (idempotency) headers[HEADER_IDEMPOTENCY] = randomUUID(); + const resp = await fetch(url, { method: 'POST', headers, body }); + const text = await resp.text(); + console.log(`-> ${resp.status} ${text}`); + return { status: resp.status, text }; +} + +/** Fire-and-collect: 202 now, reply later on your callback URL. */ +export function push(url: string, secret: string, session: string, text: string, sessionType = 'person') { + return post(url.replace(/\/$/, ''), secret, { + session_id: session, + session_type: sessionType, + message: [{ type: 'Plain', text }] as Segment[], + }); +} + +/** Blocking convenience: POST /sync, get the collapsed reply. */ +export function pushSync(url: string, secret: string, session: string, text: string, sessionType = 'person') { + return post(`${url.replace(/\/$/, '')}/sync`, secret, { + session_id: session, + session_type: sessionType, + message: [{ type: 'Plain', text }] as Segment[], + }, false); +} + +/** Reset a session's conversation. */ +export function reset(url: string, secret: string, session: string, sessionType = 'person') { + return post(`${url.replace(/\/$/, '')}/reset`, secret, { session_id: session, session_type: sessionType }, false); +} + +/** Run a callback receiver that verifies signatures and prints replies. */ +export function startReceiver(port: number, secret: string) { + const server = createServer((req, res) => { + if (req.method !== 'POST') { res.writeHead(405).end(); return; } + const chunks: Buffer[] = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const raw = Buffer.concat(chunks); + const ok = verify(secret, raw, req.headers[HEADER_TIMESTAMP.toLowerCase()] as string, + req.headers[HEADER_SIGNATURE.toLowerCase()] as string); + if (!ok) { + console.log('!! signature verification FAILED — rejecting'); + res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'bad signature' })); + return; + } + const data = JSON.parse(raw.toString()); + const parts = (data.message as Segment[]).filter((c) => c.type === 'Plain').map((c) => c.text).join(' '); + const marker = data.is_final ? 'FINAL' : 'part '; + console.log(`[${marker}] session=${data.session_id} seq=${data.sequence} reply_to=${data.reply_to}: ${parts}`); + res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true })); + }); + }); + server.listen(port, () => console.log(`callback receiver listening on http://0.0.0.0:${port}/ (Ctrl-C to stop)`)); +} + +// --- CLI --- +const [cmd, ...rest] = process.argv.slice(2); +if (cmd === 'serve') { + startReceiver(Number(rest[0] ?? 8900), rest[1] ?? 'SHARED_SECRET'); +} else if (cmd === 'push') { + push(rest[0], rest[1], rest[2], rest[3]); +} else if (cmd === 'sync') { + pushSync(rest[0], rest[1], rest[2], rest[3]); +} else if (cmd === 'reset') { + reset(rest[0], rest[1], rest[2]); +} else if (cmd) { + console.error(`unknown command: ${cmd}`); + process.exit(1); +} diff --git a/examples/http-bot/playground.py b/examples/http-bot/playground.py new file mode 100644 index 000000000..ea77d26b9 --- /dev/null +++ b/examples/http-bot/playground.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +"""LangBot HTTP Bot — interactive playground (public, browser-based). + +This is a REAL end-to-end demo against the RUNNING LangBot instance on this +host. It is NOT a mock and NOT an in-process import: every message you type in +the browser is signed and POSTed to the live `http_bot` bot at +http://127.0.0.1:5300/bots/, and the bot's replies come back to this +server's /callback endpoint over real HTTP, then stream to your browser via SSE. + +What it does on startup: + 1. Reads the LangBot API key + the http_bot bot from data/langbot.db. + 2. Configures the bot via the LangBot API (PUT /api/v1/platform/bots/): + sets inbound_secret + outbound_secret + callback_url to point back here. + (LangBot reloads the bot live — no server restart needed.) + 3. Serves a chat page on 0.0.0.0: so you can open it from the internet. + +Run: ./.venv/bin/python examples/http-bot/playground.py +Then open: http://:/ +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sqlite3 +import sys + +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, os.path.join(REPO, 'src')) + +from aiohttp import web # noqa: E402 +import aiohttp # noqa: E402 + +from langbot.pkg.platform.sources import http_bot_signing as sg # noqa: E402 + +# ---- config ----------------------------------------------------------------- +LANGBOT_BASE = 'http://127.0.0.1:5300' +DB_PATH = os.path.join(REPO, 'data', 'langbot.db') +PUBLIC_IP = os.environ.get('PUBLIC_IP', '127.0.0.1') +PORT = int(os.environ.get('PLAYGROUND_PORT', '8920')) +SECRET = 'playground-shared-secret' + +# SSE subscribers: list of asyncio.Queue +subscribers: list[asyncio.Queue] = [] + + +def db_lookup() -> tuple[str, str]: + """Return (api_key, http_bot_uuid) from the LangBot DB.""" + db = sqlite3.connect(DB_PATH) + db.row_factory = sqlite3.Row + api_key = db.execute('SELECT key FROM api_keys LIMIT 1').fetchone()['key'] + bot = db.execute("SELECT uuid FROM bots WHERE adapter='http_bot' LIMIT 1").fetchone() + if not bot: + raise SystemExit('No http_bot bot found. Create one in the WebUI first.') + return api_key, bot['uuid'] + + +async def configure_bot(api_key: str, bot_uuid: str, callback_url: str): + """Point the live bot at this playground via the LangBot API. + + update_bot() runs a raw SQL UPDATE with whatever keys we send, so we send a + MINIMAL payload: only adapter_config (built from scratch, not read back — + the GET masks secrets). LangBot reloads + reruns the bot live. + """ + cfg = { + 'inbound_secret': SECRET, + 'outbound_secret': SECRET, + 'callback_url': callback_url, + 'signature_required': True, + 'default_session_type': 'person', + 'callback_timeout': 15, + 'callback_max_retries': 3, + } + async with aiohttp.ClientSession() as s: + async with s.put( + f'{LANGBOT_BASE}/api/v1/platform/bots/{bot_uuid}', + headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'}, + json={'adapter_config': cfg}, + ) as r: + txt = await r.text() + print(f'[configure] PUT adapter_config -> {r.status} {txt[:200]}') + return r.status < 400 + + +async def broadcast(event: dict): + for q in list(subscribers): + try: + q.put_nowait(event) + except Exception: + pass + + +# ---- HTTP handlers ---------------------------------------------------------- +async def index(request: web.Request): + return web.Response(text=PAGE, content_type='text/html') + + +async def send(request: web.Request): + """Browser -> here -> signed POST -> live LangBot bot.""" + body_in = await request.json() + session_id = body_in.get('session_id') or 'playground-1' + text = body_in.get('text', '') + bot_uuid = request.app['bot_uuid'] + + payload = { + 'session_id': session_id, + 'sender': {'id': 'browser-user', 'name': 'You'}, + 'message': [{'type': 'Plain', 'text': text}], + } + raw = json.dumps(payload, ensure_ascii=False).encode() + ts, sig = sg.sign(SECRET, raw) + url = f'{LANGBOT_BASE}/bots/{bot_uuid}' + + # echo what we send to the browser timeline + await broadcast( + {'dir': 'out', 'kind': 'request', 'session_id': session_id, 'text': text, 'url': url, 'sig': sig[:24] + '…'} + ) + + async with aiohttp.ClientSession() as s: + async with s.post( + url, + data=raw, + headers={ + 'Content-Type': 'application/json', + sg.HEADER_TIMESTAMP: ts, + sg.HEADER_SIGNATURE: sig, + }, + ) as r: + status = r.status + try: + jr = await r.json() + except Exception: + jr = {'raw': await r.text()} + await broadcast({'dir': 'in', 'kind': 'ack', 'status': status, 'data': jr}) + return web.json_response({'status': status, 'data': jr}) + + +async def callback(request: web.Request): + """Live LangBot bot -> here. Verify signature, stream to browser.""" + raw = await request.read() + ok, why = sg.verify(SECRET, raw, request.headers.get(sg.HEADER_TIMESTAMP), request.headers.get(sg.HEADER_SIGNATURE)) + data = json.loads(raw) + text = ' '.join(c.get('text', '') for c in data.get('message', []) if c.get('type') == 'Plain') + await broadcast( + { + 'dir': 'in', + 'kind': 'reply', + 'session_id': data.get('session_id'), + 'sequence': data.get('sequence'), + 'is_final': data.get('is_final'), + 'sig_ok': ok, + 'sig_why': why, + 'text': text, + } + ) + return web.json_response({'ok': True}) + + +async def events(request: web.Request): + """SSE stream to the browser.""" + resp = web.StreamResponse( + headers={ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + } + ) + await resp.prepare(request) + q: asyncio.Queue = asyncio.Queue() + subscribers.append(q) + try: + await resp.write(b': connected\n\n') + while True: + try: + ev = await asyncio.wait_for(q.get(), timeout=15) + await resp.write(f'data: {json.dumps(ev, ensure_ascii=False)}\n\n'.encode()) + except asyncio.TimeoutError: + await resp.write(b': ping\n\n') + except (asyncio.CancelledError, ConnectionResetError): + pass + finally: + if q in subscribers: + subscribers.remove(q) + return resp + + +PAGE = r""" + + +LangBot HTTP Bot · 调试台 + + +
+ + HTTP Bot 调试台examples/http-bot + 连接中… +
+
+ +
+

对话 · 真实发往运行中的 http_bot

+
+
+ + +
+
+ +
+

调试信息

+
入站地址
/bots/<uuid>
+
签名 HMAC-SHA256 · X-LB-Signature
+
+ 会话 + + +
+
+
+
+ +""" + + +async def main(): + api_key, bot_uuid = db_lookup() + callback_url = f'http://{PUBLIC_IP}:{PORT}/callback' + print(f'[init] http_bot uuid = {bot_uuid}') + print(f'[init] callback_url = {callback_url}') + ok = await configure_bot(api_key, bot_uuid, callback_url) + if not ok: + print('[warn] bot config update failed; check the API key / payload shape') + + app = web.Application() + app['bot_uuid'] = bot_uuid + app.router.add_get('/', index) + app.router.add_post('/send', send) + app.router.add_post('/callback', callback) + app.router.add_get('/events', events) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, '0.0.0.0', PORT) + await site.start() + print(f'\n ▶ 打开: http://{PUBLIC_IP}:{PORT}/\n') + while True: + await asyncio.sleep(3600) + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/web-page-bot/README.md b/examples/web-page-bot/README.md new file mode 100644 index 000000000..e31f41ca2 --- /dev/null +++ b/examples/web-page-bot/README.md @@ -0,0 +1,48 @@ +# Page Bot Adapter — Embed Demo + +> English | [中文](./README.zh.md) + +A single self-contained HTML page that demos the LangBot **Page Bot** +(`web_page_bot`) embeddable chat widget — the one you drop onto any website with +a single ``. +- `widget.js` is served by LangBot pre-configured for that bot UUID — title, + bubble icon, language and optional Cloudflare Turnstile protection all come + from the bot's config, no page changes needed. +- Messages travel over a WebSocket to the bot's bound pipeline; replies stream + back into the bubble. + +> The widget loads `widget.js` from your LangBot instance, so the **base URL +> must be reachable from the browser** you open this page in. If LangBot runs on +> a server, use its public address instead of `localhost`. diff --git a/examples/web-page-bot/README.zh.md b/examples/web-page-bot/README.zh.md new file mode 100644 index 000000000..9006464d8 --- /dev/null +++ b/examples/web-page-bot/README.zh.md @@ -0,0 +1,44 @@ +# 页面机器人适配器 —— 嵌入演示 + +> [English](./README.md) | 中文 + +一个自包含的单文件 HTML 页面,用于演示 LangBot **页面机器人** +(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 ``。 +- `widget.js` 由 LangBot 针对该机器人 UUID 预配置后下发 —— 标题、气泡图标、语言 + 以及可选的 Cloudflare Turnstile 防护,全部来自机器人配置,无需改动页面。 +- 消息通过 WebSocket 发往机器人绑定的流水线,回复流式回到气泡中。 + +> 组件会从你的 LangBot 实例加载 `widget.js`,因此 **base URL 必须能从你打开本页 +> 的浏览器访问到**。如果 LangBot 部署在服务器上,请用它的公网地址而非 +> `localhost`。 diff --git a/examples/web-page-bot/index.html b/examples/web-page-bot/index.html new file mode 100644 index 000000000..ef88906f1 --- /dev/null +++ b/examples/web-page-bot/index.html @@ -0,0 +1,205 @@ + + + + + +LangBot Page Bot · Embed Demo + + + +
+ + Page Bot · Embed Demo + examples/web-page-bot +
+ +
+
+

Try the LangBot Page Bot widget

+

Point this page at a running LangBot instance and a Page Bot you created,

+

then load the live embed widget below to chat with it — exactly as your site visitors would.

+
+ +
+

1 Connect your Page Bot

+
+ + +
The address where your LangBot instance is reachable from this browser. No trailing slash.
+
+
+ + +
Create a bot with the Page Bot adapter in the WebUI, then copy its UUID from the embed code.
+
+
+ + +
+
+ + + Not loaded. +
+
+ +
+

2 The embed snippet

+

This is exactly what you paste into your own site (before </body>). It updates as you edit the fields above.

+
+ +
<script data-title="LangBot" src="http://localhost:5300/api/v1/embed/<bot-uuid>/widget.js"></script>
+
+
+ +
+

3 How it works

+
    +
  • The <script> tag pulls widget.js from your LangBot instance, pre-configured for that bot UUID.
  • +
  • A floating chat bubble appears in the bottom-right corner of the page.
  • +
  • Messages travel over a WebSocket to the bot's bound pipeline; replies stream back into the bubble.
  • +
  • Title, bubble icon, language and optional Cloudflare Turnstile protection are all set in the bot's config — no page changes needed.
  • +
+
+
+ + + + diff --git a/pyproject.toml b/pyproject.toml index 8c5fe6512..3204bd35d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.9.7" +version = "4.10.5" description = "Production-grade platform for building agentic IM bots" readme = "README.md" license-files = ["LICENSE"] @@ -8,7 +8,7 @@ requires-python = ">=3.11,<4.0" dependencies = [ "aiocqhttp>=1.4.4", "aiofiles>=24.1.0", - "aiohttp>=3.13.4", + "aiohttp>=3.14.1", "aioshutil>=1.5", "aiosqlite>=0.21.0", "anthropic>=0.51.0", @@ -16,7 +16,7 @@ dependencies = [ "async-lru>=2.0.5", "certifi>=2025.4.26", "colorlog~=6.6.0", - "cryptography>=46.0.7", + "cryptography>=48.0.1", "dashscope>=1.25.10", "dingtalk-stream>=0.24.0", "discord-py>=2.5.2", @@ -31,27 +31,27 @@ dependencies = [ "psutil>=7.0.0", "pycryptodome>=3.22.0", "pydantic>2.0", - "pyjwt>=2.10.1", + "pyjwt>=2.12.0", "python-telegram-bot>=22.0", "pyyaml>=6.0.2", "qq-botpy-rc>=1.2.1.6", "qrcode>=7.4", "quart>=0.20.0", "quart-cors>=0.8.0", - "requests>=2.32.3", + "requests>=2.33.0", "slack-sdk>=3.35.0", "alembic>=1.15.0", "sqlalchemy[asyncio]>=2.0.40", "sqlmodel>=0.0.24", "telegramify-markdown>=0.5.1", "tiktoken>=0.9.0", - "urllib3>=2.4.0", + "urllib3>=2.7.0", "websockets>=15.0.1", "python-socks>=2.7.1", # dingtalk missing dependency - "pip>=25.1.1", + "pip>=26.1", "ruff>=0.11.9", "pre-commit>=4.2.0", - "uv>=0.11.6", + "uv>=0.11.15", "mypy>=1.16.0", "PyPDF2>=3.0.1", "python-docx>=1.1.0", @@ -61,16 +61,16 @@ dependencies = [ "beautifulsoup4>=4.12.3", "ebooklib>=0.18", "html2text>=2024.2.26", - "langchain>=0.2.0", - "langchain-core>=1.2.28", - "langsmith>=0.7.31", - "python-multipart>=0.0.26", - "Mako>=1.3.11", + "langchain>=1.3.9", + "langchain-core>=1.3.3", + "langsmith>=0.8.18", + "python-multipart>=0.0.27", + "Mako>=1.3.12", "langchain-text-splitters>=1.1.2", "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", "pyseekdb==1.1.0.post3", - "langbot-plugin==0.3.11", + "langbot-plugin==0.4.13", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", @@ -79,6 +79,8 @@ dependencies = [ "pymilvus>=2.6.4", "pgvector>=0.4.1", "botocore>=1.42.39", + "litellm>=1.0.0", + "valkey-glide>=2.4.1,<3.0.0", ] keywords = [ "bot", @@ -223,4 +225,3 @@ skip-magic-trailing-comma = false # Like Black, automatically detect the appropriate line ending. line-ending = "auto" - diff --git a/res/dashboard-overview.png b/res/dashboard-overview.png new file mode 100644 index 000000000..459a3ec4f Binary files /dev/null and b/res/dashboard-overview.png differ diff --git a/skills/.gitignore b/skills/.gitignore new file mode 100644 index 000000000..daeb4d92b --- /dev/null +++ b/skills/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +coverage/ +.tap/ +__pycache__/ +*.pyc +skills/.env.local +reports/ +skills/*/reports/ +.browser/ diff --git a/skills/AGENTS.md b/skills/AGENTS.md new file mode 100644 index 000000000..c42412efc --- /dev/null +++ b/skills/AGENTS.md @@ -0,0 +1,68 @@ +# Agent Workflow + +This repository stores reusable LangBot agent-testing assets. Keep changes structured so the next agent does not need to rediscover paths. + +## First Steps + +1. Read `skills/.env` before using local URLs, paths, browser profiles, or proxy defaults. If present, `skills/.env.local` overrides it for this machine and must not be committed. On a new machine, copy `skills/.env.example` to `skills/.env.local` first. +2. Pick the smallest relevant skill: + - `langbot-env-setup` for environment, browser, OAuth, proxy, and startup. + - `langbot-testing` for WebUI, provider, pipeline, cases, and troubleshooting. + - `langbot-skills-maintenance` for adding, deduplicating, or auditing this skills repository. +3. Prefer existing cases and troubleshooting entries before exploring from scratch. + +## Editing Rules + +- UI/browser testing is the primary QA path. API/curl checks are diagnostic only and cannot make a UI case pass by themselves. +- Put skills under `skills//`. +- Keep `SKILL.md` concise; move detailed workflows to `references/`. +- Put reusable test paths in `cases/*.yaml`. +- New or edited cases must include `priority`, `risk`, `ci_eligible`, and `evidence_required` so agents can select the right test set without rereading every file. +- Use `env_any` / `automation_env_any` for one-of machine inputs, such as `LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME`; do not list those alternatives as separate all-required env keys. +- Put reusable groups of cases in `suites/*.yaml` rather than hardcoding test sets in docs or CLI code. +- Put growing failure knowledge in `troubleshooting/*.yaml`. +- Do not hardcode local ports in testing docs; use `skills/.env` variables and machine-local `skills/.env.local` overrides. +- Do not store secrets, API keys, OAuth tokens, or localStorage token values. + +## Required Checks + +After structural changes, run: + +```bash +bin/lbs validate +``` + +After changing skills, cases, or troubleshooting assets, run: + +```bash +bin/lbs index +``` + +Use `bin/lbs env show` to inspect defaults and `bin/lbs env doctor` when diagnosing local environment readiness. Env output is redacted by default; do not work around that by printing raw secrets. +`bin/lbs` is a generated local wrapper. If it is missing on a fresh checkout, run `npm run bootstrap` from this directory first; `npm install` also regenerates it via `prepare`. +Use `bin/lbs fixture check` before fixture-heavy cases such as MCP, RAG, multimodal, or plugin smoke tests. +Use `bin/lbs case list --ready` for cases that have no missing machine inputs and no manual preconditions. Use `bin/lbs case list --machine-ready` when you want to keep `manual-check` candidates and confirm their preconditions yourself. + +Before executing a saved QA path, generate the agent-facing plan: + +```bash +bin/lbs test plan +``` + +Read the plan readiness sections before running the browser path. Missing env, +automation env, or fixture readiness means the case is not ready to execute and +should be marked `blocked` or fixed first. +`manual_check` means machine inputs are present but the agent must verify the +declared `preconditions` or `setup` items before executing the UI path. Do not +turn a `manual_check` case into `pass` until those items were checked in the +same run. + +Before executing a group of saved QA paths, generate the suite plan: + +```bash +bin/lbs suite plan +``` + +Use `bin/lbs suite start ` to create a shared suite run id, suite evidence root, per-case evidence directories, and `suite-start.json`/`suite-start.md` handoff files. Then run `bin/lbs suite report --evidence-dir ` to aggregate case results. +Automation scripts write `automation-result.json`; write the final per-case `result.json` with `bin/lbs test result --result --reason --evidence-dir --evidence ` after collecting the required evidence. A `pass` result must include all required evidence. +For runner-specific Debug Chat cases, prefer case-specific pipeline env keys such as `LANGBOT_LOCAL_AGENT_PIPELINE_URL` over the generic `LANGBOT_PIPELINE_URL`; otherwise an agent can accidentally test the wrong runner. diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 000000000..091e3d3b1 --- /dev/null +++ b/skills/README.md @@ -0,0 +1,59 @@ +# LangBot Skills + +This directory is the **single source of truth** for LangBot's agent skills — +reusable, on-demand instruction packs for AI agents (Claude Code, Codex, Cursor, +and LangBot's own Local Agent) working with the LangBot ecosystem. + +> These skills were consolidated here from the former `langbot-app/langbot-skills` +> repository (now archived). Documentation and the landing page link here; do not +> re-copy skill content elsewhere — link to this directory instead. + +## Skill catalog + +| Skill | What it covers | +| --- | --- | +| [`langbot-dev`](skills/langbot-dev) | Core backend + web frontend development (Quart, Vite, API, migrations, MCP server) | +| [`langbot-plugin-dev`](skills/langbot-plugin-dev) | Plugin SDK / component development, debugging, WebSocket testing | +| [`langbot-deploy`](skills/langbot-deploy) | Docker / Compose / Kubernetes deployment, config.yaml, Box runtime, global API key | +| [`langbot-testing`](skills/langbot-testing) | WebUI / e2e QA harness, cases, fixtures, troubleshooting (the `bin/lbs` CLI) | +| [`langbot-env-setup`](skills/langbot-env-setup) | Local dev/test environment, browser access, OAuth, proxy, startup | +| [`langbot-mcp-ops`](skills/langbot-mcp-ops) | Operating a LangBot instance through its MCP server (`/mcp`) | +| [`langbot-space-ops`](skills/langbot-space-ops) | Browsing the LangBot Space marketplaces through the Space MCP server | +| [`langbot-eba-adapter-dev`](skills/langbot-eba-adapter-dev) | Building platform adapters for the Event-Based Agents architecture | +| [`langbot-skills-maintenance`](skills/langbot-skills-maintenance) | Adding, deduplicating, and auditing skills in this directory | + +`skills.index.json` is the machine-readable index (regenerate with `bin/lbs index`). + +## Quick start (for an AI agent) + +1. Read this README, `AGENTS.md`, and `docs/user-guide.md` to understand the layout. +2. Read `skills/.env` for shared local defaults. On a new machine, copy + `skills/.env.example` to `skills/.env.local` (gitignored) and override + machine-specific values there. Never commit secrets. +3. Pick the smallest relevant skill from the catalog above and follow its + `SKILL.md`. + +## The `lbs` CLI + +The testing assets ship with a small CLI (`bin/lbs`, Node >= 22.6). The +`bin/lbs` wrapper is a generated local entrypoint; on a fresh checkout, run +`npm run bootstrap` once if it is missing. `npm install` also regenerates it via +the `prepare` script. + +```bash +npm run bootstrap # create bin/lbs if missing +bin/lbs validate # validate skills/cases/troubleshooting structure +bin/lbs index # regenerate skills.index.json +bin/lbs env show # inspect resolved env defaults (redacted) +bin/lbs env doctor # diagnose local environment readiness +bin/lbs case list --ready +bin/lbs test plan +bin/lbs suite plan langbot-debug-chat-load-gate +``` + +## Maintenance rule + +When the LangBot / LangBot Space **API or MCP server changes**, the +corresponding skill here MUST be updated in the same change. The MCP tool +surface, the API, and these skills are kept in lockstep — see each repo's +`AGENTS.md`. diff --git a/skills/docs/user-guide.md b/skills/docs/user-guide.md new file mode 100644 index 000000000..124d3af36 --- /dev/null +++ b/skills/docs/user-guide.md @@ -0,0 +1,171 @@ +# LangBot QA Skills User Guide + +Use this guide as the first operational path after reading `README.md` and +`AGENTS.md`. + +## 1. Configure Local Inputs + +Read `skills/.env`, then create `skills/.env.local` for machine-local values. +Do not commit `.env.local`, browser profiles, reports, tokens, API keys, OAuth +state, or provider credentials. + +Minimum local fields for live browser QA: + +```bash +LANGBOT_REPO=/path/to/LangBot +LANGBOT_WEB_REPO=/path/to/LangBot/web +LANGBOT_BACKEND_URL=http://127.0.0.1:5300 +LANGBOT_FRONTEND_URL=http://127.0.0.1:3000 +LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:3000 +LANGBOT_BROWSER_PROFILE=/path/to/langbot-browser-profile +LANGBOT_CHROMIUM_EXECUTABLE=/path/to/chromium-or-playwright-chrome +LANGBOT_E2E_LOGIN_USER=qa-local@example.com +``` + +`LANGBOT_E2E_LOGIN_USER` is a local QA account. The setup automation uses the +LangBot recovery key from the active checkout to initialize or refresh that +local account and write a browser `localStorage` token. It does not need the +user's GitHub or Space credentials. + +## 2. Check Readiness + +From `skills/`: + +```bash +bin/lbs env show +bin/lbs env doctor +bin/lbs validate +bin/lbs index --check +``` + +`env doctor` should report reachable backend and frontend URLs before live +browser cases are run. Missing Space provider credentials are not a LangBot +product pass; classify them as `env_issue` and configure the local Space +provider before measuring Debug Chat performance. + +## 3. Start Services + +Start the backend from `LANGBOT_REPO`: + +```bash +cd "$LANGBOT_REPO" +uv run main.py +``` + +Start the standalone frontend from `LANGBOT_WEB_REPO` and point it at the +backend: + +```bash +cd "$LANGBOT_WEB_REPO" +VITE_API_BASE_URL="$LANGBOT_BACKEND_URL" pnpm dev --host 0.0.0.0 +``` + +If `VITE_API_BASE_URL` is missing, browser tests can load the Vite page but send +API requests to the frontend port, which produces false UI failures. + +## 4. Prepare User-Path Fixtures + +For local-agent Debug Chat cases and the user-path performance gate: + +```bash +node scripts/e2e/ensure-local-agent-pipeline.mjs --write-env +``` + +The script: + +- refreshes the local QA login and browser token; +- marks the local wizard as skipped; +- creates or updates a local QA pipeline; +- scans Space LLM models, tests candidates, and switches to the first working + Space model with tested fallback models; +- writes `LANGBOT_PIPELINE_URL`, `LANGBOT_PIPELINE_NAME`, and local-agent + pipeline/model variables into `skills/.env.local`; +- returns `env_issue` when no Space model can be scanned or tested. + +Useful model controls: + +```bash +LANGBOT_E2E_MODEL_TEST_LIMIT=8 +LANGBOT_E2E_MODEL_FALLBACK_COUNT=3 +LANGBOT_E2E_SKIP_MODEL_UUIDS=uuid-a,uuid-b +LANGBOT_E2E_SKIP_MODEL_NAMES=model-a,model-b +LANGBOT_E2E_SCAN_SPACE_MODELS=true +``` + +The setup writes a current-runtime compatibility `max-round` value into the +pipeline config because this backend still reads that field directly during +message truncation. Do not treat it as a long-term QA contract. + +## 5. Run Gates + +Fast contract gate, no live service required: + +```bash +bin/lbs suite run langbot-performance-contract-gate --run-id langbot-contract-local +``` + +Live backend gate: + +```bash +bin/lbs suite run langbot-live-backend-gate --run-id langbot-backend-local +``` + +Browser-visible user-path performance gate: + +```bash +bin/lbs suite plan langbot-user-path-performance-gate +bin/lbs suite run langbot-user-path-performance-gate --run-id langbot-user-path-local --include-manual-check +``` + +Controlled Debug Chat message-path load gate (manual/non-required; run fake-provider cases serially when they share `LANGBOT_FAKE_PROVIDER_URL`): + +```bash +bin/lbs suite plan langbot-debug-chat-load-gate +bin/lbs test run langbot-fake-provider-debug-chat-load --run-id langbot-fake-load-local +bin/lbs test run langbot-fake-provider-debug-chat-slow-load --run-id langbot-fake-slow-local +bin/lbs test run langbot-fake-provider-debug-chat-fault-recovery --run-id langbot-fake-fault-local +bin/lbs test run langbot-space-debug-chat-concurrency-smoke --run-id langbot-space-smoke-local +``` + +Cross-pipeline Debug Chat isolation is a separate manual regression gate because +current releases may fail it due to product bug #2286: + +```bash +bin/lbs suite plan langbot-debug-chat-isolation-gate +bin/lbs suite run langbot-debug-chat-isolation-gate --run-id langbot-debug-chat-isolation-local --include-manual-check +``` + +Start with `langbot-fake-provider-debug-chat-load`. It launches a local +OpenAI-compatible fake provider, creates the matching provider/model/pipeline, +then sends concurrent WebSocket Debug Chat messages through the real backend. +Use `langbot-fake-provider-debug-chat-slow-load` to measure the same path under +deterministic streaming latency. Use +`langbot-fake-provider-debug-chat-fault-recovery` to inject bounded provider +HTTP failures and confirm later Debug Chat requests recover. Use the separate +`langbot-debug-chat-isolation-gate` to verify that concurrent Debug Chat traffic +on two pipelines does not leak assistant responses across pipeline boundaries; +current releases may fail that gate because of #2286, so keep it out of the +normal load gate until the product fix lands. +Use `langbot-space-debug-chat-concurrency-smoke` only as a low-volume live +provider smoke; it includes Space/model/network latency and should be compared +against the fake-provider baseline before attributing failures to LangBot. + +`manual_check` means the agent must confirm the declared preconditions for that +run window. When setup automation is declared, run output may stop early with +`env_issue`; fix that environment input before treating the product path as +measured. + +## 6. Read Results + +Suite reports live under `skills/reports/`. Evidence lives under +`skills/reports/evidence//`. + +For performance cases, inspect: + +- `metrics.json` for p50/p95/p99, error rate, and total duration; +- `automation-result.json` for threshold decisions and artifacts; +- `console.log` and `network.log` for frontend/API failures; +- backend logs for provider, runner, WebSocket, or persistence failures. + +Do not call a user-path performance result a LangBot overhead regression until +provider/tool/network time has been separated or ruled out. diff --git a/skills/package.json b/skills/package.json new file mode 100644 index 000000000..4b9f42be7 --- /dev/null +++ b/skills/package.json @@ -0,0 +1,29 @@ +{ + "private": true, + "type": "module", + "bin": { + "lbs": "./bin/lbs" + }, + "scripts": { + "bootstrap": "node scripts/bootstrap-lbs.mjs", + "prepare": "node scripts/bootstrap-lbs.mjs", + "prevalidate": "node scripts/bootstrap-lbs.mjs", + "preindex": "node scripts/bootstrap-lbs.mjs", + "preindex:check": "node scripts/bootstrap-lbs.mjs", + "pretest": "node scripts/bootstrap-lbs.mjs", + "precheck": "node scripts/bootstrap-lbs.mjs", + "lbs": "node src/lbs.ts", + "test": "node test/lbs-cli.test.ts", + "validate": "node src/lbs.ts validate", + "index": "node src/lbs.ts index", + "index:check": "node src/lbs.ts index --check", + "check:syntax": "find src test scripts -type f \\( -name '*.ts' -o -name '*.mjs' \\) -print0 | xargs -0 -n1 node --check", + "check": "npm run check:syntax && npm run validate && npm test" + }, + "engines": { + "node": ">=22.6" + }, + "devDependencies": { + "playwright": "^1.60.0" + } +} diff --git a/skills/qa-agent-docs/qa-agent/00-technology-options.md b/skills/qa-agent-docs/qa-agent/00-technology-options.md new file mode 100644 index 000000000..0a021f4fb --- /dev/null +++ b/skills/qa-agent-docs/qa-agent/00-technology-options.md @@ -0,0 +1,117 @@ +# LangBot Agent Testing 技术选型 + +## 状态 + +这是技术选型背景文档,不是当前路线图。当前黑盒 E2E QA 的实施顺序见: + +```text +docs/qa-agent/04-black-box-e2e-roadmap.md +``` + +## 目标 + +`langbot-skills` 的目标不是替代测试框架,而是沉淀 agent 可复用的测试资产,让开发者 clone 仓库后,可以让 Codex、Claude Code、Computer Use 或 Playwright MCP 复用已有路径完成 LangBot 功能验证。 + +核心原则: + +- Skill 负责路由和少量规则。 +- Reference 负责可读流程和背景知识。 +- Case 负责结构化测试路径。 +- Troubleshooting 负责结构化故障资产。 +- `lbs` 负责结构校验、索引、资产创建和未来的运行/报告能力。 +- UI/browser 是产品 QA 的主路径;API/curl 只用于诊断。 + +## 浏览器控制层 + +不同开发者可用的浏览器控制能力不同,所以浏览器层必须可替换。 + +| 方案 | 适用场景 | 优点 | 代价 | +|---|---|---|---| +| Codex / Claude Computer Use | agent 可以直接控制可见浏览器 | 登录和交互路径最自然,通常不需要额外 MCP 浏览器桥接 | 依赖具体 agent 工具能力 | +| Playwright MCP | 没有 Computer Use,但有 MCP 浏览器工具 | 稳定、可脚本化、适合回归路径 | OAuth 登录通常需要额外 visible profile | +| 直接 Playwright 脚本 | 测试路径非常稳定,适合 CI | 可重复性强 | 需要维护脚本和 selector | +| 商业 AI QA 平台 | 团队希望外包测试运行平台 | 报告和 PR 集成完整 | 成本和平台绑定 | + +## 当前推荐 + +先采用分层降级: + +```text +有 Computer Use? + 是 -> 使用 Computer Use 控制浏览器 + 否 -> 使用 Playwright MCP + +需要 GitHub OAuth? + 是 -> 使用持久浏览器 profile,让用户手动完成登录 + 否 -> 直接使用已有登录态或测试账号状态 +``` + +具体选择逻辑沉淀在: + +```text +skills/langbot-env-setup/references/browser-access-selection.md +``` + +测试原则固定在: + +```text +docs/qa-agent/03-agent-browser-qa-principles.md +``` + +## 环境变量层 + +测试文档不应写死端口。共享默认值放在: + +```text +skills/.env +``` + +关键变量: + +```text +LANGBOT_FRONTEND_URL +LANGBOT_BACKEND_URL +LANGBOT_DEV_FRONTEND_URL +LANGBOT_REPO +LANGBOT_WEB_REPO +LANGBOT_BROWSER_PROFILE +``` + +Agent 执行测试前应先读取 `skills/.env`,再用用户提供的当前环境或已启动服务覆盖默认值。 + +## 测试资产层 + +测试资产分两类: + +```text +skills// + references/ # Markdown 流程说明 + cases/ # 结构化测试用例 + troubleshooting/ # 结构化故障记录 +``` + +当前已实现: + +- `SKILL.md` 路由 +- `references/*.md` +- `lbs case new/list/show` +- `lbs trouble show/search` +- `lbs test plan` +- `lbs test report` +- `lbs list / validate / index` + +下一步重点: + +- 日志守卫规则补充 +- 报告产物管理 + +## 关键判断 + +不要强制所有内容只能通过 CLI 修改。更好的模式是: + +- 新增 case/troubleshooting:优先使用 `lbs` +- 大段流程说明:允许直接编辑 Markdown +- 结构性变更后:必须运行 `lbs validate` +- 任何生成索引的变更后:运行 `lbs index` + +这样既能沉淀结构化资产,又不会在 schema 未稳定时拖慢迭代。 diff --git a/skills/qa-agent-docs/qa-agent/01-qa-agent-harness-plan.md b/skills/qa-agent-docs/qa-agent/01-qa-agent-harness-plan.md new file mode 100644 index 000000000..06b52ee7b --- /dev/null +++ b/skills/qa-agent-docs/qa-agent/01-qa-agent-harness-plan.md @@ -0,0 +1,231 @@ +# LangBot Skills 测试资产库规划 + +## 状态 + +这是早期测试资产库规划文档,保留用于解释 `langbot-skills` 的分层来源。 + +当前路线已经收敛为黑盒 E2E QA:开发者用 agent 通过浏览器测试 LangBot, +稳定路径沉淀为 case,失败知识沉淀为 troubleshooting。`lbs test report` 和 +日志守卫已有 MVP,后续重点是报告证据、case 元数据和少量稳定路径自动化。当前优先级见: + +```text +docs/qa-agent/04-black-box-e2e-roadmap.md +``` + +本文中关于 `case list/show`、`trouble show/search`、`test plan` 的“计划实现” +内容已经部分过时,因为这些能力已经落地。 + +## 目标 + +让开发者 clone `langbot-skills` 后,可以把测试意图交给 agent,由 agent 复用已有环境配置、测试路径和故障知识完成 LangBot 功能验证。 + +典型场景: + +- 冒烟测试:验证 pipeline Debug Chat、provider、常见页面是否正常。 +- Provider 测试:添加 DeepSeek/OpenAI/Claude 等供应商并验证模型可用。 +- 新 feature 测试:探索新 UI 路径,并在稳定后沉淀成 case/reference。 +- 回归测试:复用旧路径,避免每个窗口重新探索登录、模型配置、pipeline 调试。 +- 故障沉淀:把 runtime 超时、代理不一致、WebSocket 问题记录为可搜索资产。 + +核心方向见 `03-agent-browser-qa-principles.md`:agent 必须以浏览器/UI 为主路径,API/curl 只能作为诊断手段。 + +## 当前仓库结构 + +```text +skills/ + .env # 共享默认变量 + langbot-env-setup/ # 环境准备、浏览器控制路径、代理、登录态 + langbot-testing/ # WebUI / provider / pipeline 测试入口 + langbot-plugin-dev/ # 插件开发测试 + langbot-eba-adapter-dev/ # 平台适配器开发测试 +src/ + lbs.ts # CLI 源码 +bin/ + lbs # CLI 入口 +docs/ + qa-agent/ # 规划文档,历史目录名保留 +``` + +## 设计分层 + +### 1. Skill 层 + +`SKILL.md` 只做触发和路由,不承载大段流程。 + +例子: + +```text +langbot-env-setup -> 选择 Computer Use / Playwright MCP / OAuth profile / proxy +langbot-testing -> 选择 WebUI / pipeline / provider / troubleshooting +``` + +### 2. Reference 层 + +Markdown 记录人和 agent 都能读的流程说明。 + +适合内容: + +- 如何选择浏览器控制方式 +- 如何启动/检查服务 +- 如何执行 pipeline Debug Chat +- 如何处理 OAuth 登录态 + +### 3. Case 层 + +使用 YAML 记录可重复测试路径。 + +建议结构: + +```text +skills/langbot-testing/cases/ + pipeline-debug-chat.yaml + provider-deepseek.yaml +``` + +建议格式: + +```yaml +id: pipeline-debug-chat +title: Pipeline Debug Chat returns a bot response +mode: agent-browser +area: pipeline +type: smoke +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +steps: + - Open LANGBOT_FRONTEND_URL + - Navigate to Pipelines + - Open target pipeline + - Select Debug Chat + - Send deterministic prompt +checks: + - "UI: User message appears" + - "UI: Bot message appears" + - "Console: No unexpected frontend errors" + - "Logs: Backend log includes Conversation(0) Streaming completed" +diagnostics: + - "Use API/curl only after the UI path is attempted, to distinguish frontend display failure from backend/runtime failure." +troubleshooting: + - plugin-runtime-timeout + - proxy-env-mismatch +``` + +### 4. Troubleshooting 层 + +故障资产会逐渐变大,适合结构化记录。 + +历史 Markdown 入口保留在: + +```text +skills/langbot-testing/references/troubleshooting.md +``` + +当前 canonical 结构化故障资产在: + +```text +skills/langbot-testing/troubleshooting/ + plugin-runtime-timeout.yaml + proxy-env-mismatch.yaml +``` + +### 5. CLI 层 + +`lbs` 是统一入口,不再引入独立 `qa` 命令。 + +已实现或当前可用: + +```bash +bin/lbs list +bin/lbs validate +bin/lbs index +bin/lbs new-skill +bin/lbs new-ref +bin/lbs case new pipeline-debug-chat --title "Pipeline Debug Chat" +bin/lbs case list +bin/lbs case show pipeline-debug-chat +bin/lbs trouble list +bin/lbs trouble show plugin-runtime-timeout +bin/lbs trouble search runtime +bin/lbs trouble add --title ... --symptom ... --cause ... --fix ... +bin/lbs test plan pipeline-debug-chat +bin/lbs test start pipeline-debug-chat +bin/lbs test run pipeline-debug-chat --dry-run +bin/lbs test report pipeline-debug-chat +bin/lbs test report pipeline-debug-chat --backend-log /path/to/backend.log +``` + +## 测试库位置 + +不要使用隐藏 `.qa/` 作为主测试库。测试资产应该和 skill 放在一起,便于触发和维护: + +```text +skills/langbot-testing/ + references/ + cases/ + troubleshooting/ + reports/ # 可选,本地运行产物可按需忽略或输出到外部目录 +``` + +如果未来需要项目本地测试库,可以允许 `lbs` 支持 `--workspace` 或项目根目录配置,但 canonical 资产仍保存在 `langbot-skills`。 + +## 阶段规划 + +### 阶段一:环境和测试路径沉淀 + +状态:基本完成,持续维护。 + +- `skills/.env` 管共享默认变量。 +- `langbot-env-setup` 拆出 Computer Use、Playwright MCP、OAuth profile、proxy、service startup。 +- `langbot-testing` 记录 WebUI、pipeline、provider 测试路径。 +- `lbs validate/index` 维护结构。 + +完成标准: + +- agent 可以从 `skills/.env` 和 references 中找到当前测试入口。 +- pipeline Debug Chat 这类路径不再需要从头探索。 + +### 阶段二:结构化 case/troubleshooting + +状态:主体已完成,继续补齐元数据和资产质量。 + +目标: + +- `lbs case new/list/show` +- `lbs trouble show/search` +- case id 去重、字段校验、索引生成 + +完成标准: + +- 冒烟测试路径可以用结构化 case 表示。 +- 下一个 agent 窗口可以直接读取 case 执行。 + +### 阶段三:计划和报告 + +状态:已有 MVP,继续完善。 + +目标: + +- `lbs test plan ` +- agent 按 plan 使用浏览器执行 UI QA +- `lbs test report` +- 日志守卫集成 +- 报告产物和 evidence 约定 + +完成标准: + +- agent 可以按 case plan 执行浏览器测试。 +- 结果报告包含 UI 结果、后端日志、console 错误和 troubleshooting 建议。 + +## 执行规则 + +- agent 可以直接编辑 Markdown reference。 +- 新增结构化 case/troubleshooting 时,优先使用 `lbs`。 +- 每次结构变更后运行 `bin/lbs validate`。 +- 每次索引相关变更后运行 `bin/lbs index`。 +- 测试文档不写死端口,使用 `skills/.env` 中的 URL 变量。 +- 测试 case 的 `mode` 固定为 `agent-browser`。 +- API/curl 只能写入 `diagnostics`,不能替代 UI 步骤和 UI 检查。 diff --git a/skills/qa-agent-docs/qa-agent/02-log-guard-plan.md b/skills/qa-agent-docs/qa-agent/02-log-guard-plan.md new file mode 100644 index 000000000..a166b1378 --- /dev/null +++ b/skills/qa-agent-docs/qa-agent/02-log-guard-plan.md @@ -0,0 +1,161 @@ +# 日志守卫规划 + +## 状态 + +这是当前活跃设计,已有第一版文件扫描 MVP。实现边界需要和黑盒 E2E 路线保持一致: + +- 日志守卫服务于 `lbs test report`。 +- 它不替代浏览器/UI 判断。 +- 它不发展成独立后端 API 测试框架。 +- 第一版默认扫描 `LANGBOT_REPO/data/logs/` 下最新的 `langbot-*.log`,也可扫描 agent + 显式提供的 backend/frontend/console 日志文件。 + +当前总体路线见: + +```text +docs/qa-agent/04-black-box-e2e-roadmap.md +``` + +## 目标 + +日志守卫是 `lbs test report` 的一部分,用来在 agent 执行测试期间捕获 UI 断言之外的运行时问题。 + +当前命令方向已收敛为 `lbs test plan` / `lbs test report`。日志守卫服务于 agent-browser QA,不是独立的后端 API 测试入口。 + +LangBot 是异步且集成度高的系统,有些问题不会直接表现为页面失败: + +- 后台任务异常 +- 未等待的协程 +- Provider 流式调用失败 +- 插件 runtime 超时 +- 平台发送失败 +- 数据库连接问题 +- 敏感信息泄露 + +日志守卫负责把这些信号结构化地放进测试报告,并关联到 troubleshooting 资产。 + +## 输入 + +日志守卫应从环境和运行上下文读取配置: + +- `skills/.env` 中的 `LANGBOT_BACKEND_URL` +- `skills/.env` 中的 `LANGBOT_REPO`,用于自动发现 LangBot 后端日志 +- `lbs test plan` / report 记录的 case id +- LangBot 后端进程输出 +- 前端 dev server 输出 +- 浏览器 console/network 错误 +- case 声明的 success/failure patterns 和 expected failures + +## MVP 范围 + +- 读取一个或多个日志流或日志文件。 +- 检测错误模式。 +- 支持按 case id 或 pattern 白名单。 +- 输出 JSON/Markdown 摘要。 +- 发现非预期错误时让测试报告标记失败;未来如果有自动执行器,再返回非零退出码。 + +## 错误分类 + +### 永远非预期 + +除非 case 明确声明,否则应失败: + +- `Traceback` +- `Task exception was never retrieved` +- `RuntimeWarning: coroutine .* was never awaited` +- `Unclosed client session` +- `Unclosed connector` +- `KeyError` +- `TypeError` +- `AttributeError` +- 密钥、token、secret 明文泄露 + +### Case 预期错误 + +只有当前 case 声明时允许: + +- 无效 provider key +- Provider 认证失败 +- 无效 webhook payload +- 插件测试故意抛错 +- 超时测试 +- 限流测试 + +### 仅警告 + +报告但默认不失败: + +- 可恢复重试 +- 恢复的超时 +- 废弃配置 +- 慢请求 +- 版本检查失败 + +## 与 Troubleshooting 集成 + +日志守卫不只输出错误文本,还应尽量匹配已知 troubleshooting id。 + +例子: + +```text +Action list_plugins call timed out +Action list_agent_runners call timed out +Action invoke_llm_stream call timed out +``` + +可映射到: + +```text +plugin-runtime-timeout +``` + +```text +uppercase proxy points to one host, lowercase proxy points to another +``` + +可映射到: + +```text +proxy-env-mismatch +``` + +## 未来命令 + +```bash +bin/lbs test plan pipeline-debug-chat +bin/lbs test start pipeline-debug-chat +bin/lbs test run pipeline-debug-chat --dry-run +bin/lbs test report pipeline-debug-chat +bin/lbs test report --output report.md +bin/lbs test report pipeline-debug-chat --backend-log /path/to/backend.log --console-log /path/to/console.log +bin/lbs test report pipeline-debug-chat --since "2026-05-21T10:30:00+08:00" +bin/lbs test report pipeline-debug-chat --tail-lines 2000 +bin/lbs test report pipeline-debug-chat --since "2026-05-21T10:30:00+08:00" --tail-lines 2000 +bin/lbs test report pipeline-debug-chat --no-auto-log +``` + +运行报告应包含: + +- case id +- URL 和环境变量摘要,不能包含 secrets +- 浏览器可见结果 +- 后端日志摘要 +- console/network 错误 +- 匹配到的 troubleshooting id +- 通过/失败结论 + +## MVP 完成标准 + +- 可以自动扫描最新 LangBot 后端日志,也可以扫描前端日志和 console 日志文件。 +- 可以用 `--since` 或 `--tail-lines` 把扫描范围限制到本次测试窗口。 +- 可以检测明显 Python/运行时错误和 secret 泄露风险。 +- 可以识别 case 声明的 success/failure patterns。 +- 可以识别 troubleshooting pattern,包括 `plugin-runtime-timeout` 和 `proxy-env-mismatch`。 +- 支持 case 级白名单。 +- 输出机器可读摘要。 +- 至少一个 `langbot-testing` case 使用它。 + +当前 MVP 已覆盖自动发现 LangBot 后端日志、文件扫描、`--since`/`--tail-lines` 扫描窗口、 +基础错误检测、case success/failure signal、troubleshooting 匹配、secret 脱敏和 `--json` +输出。仍待继续完善的是 live log 采集、更多规则、case 级 expected failure 的资产化和真实 +E2E report 样例。 diff --git a/skills/qa-agent-docs/qa-agent/03-agent-browser-qa-principles.md b/skills/qa-agent-docs/qa-agent/03-agent-browser-qa-principles.md new file mode 100644 index 000000000..15a18c21a --- /dev/null +++ b/skills/qa-agent-docs/qa-agent/03-agent-browser-qa-principles.md @@ -0,0 +1,57 @@ +# Agent Browser QA Principles + +This document fixes the direction of LangBot agent testing so the project does not drift into a backend API smoke-test framework. + +## Primary Goal + +`langbot-skills` should help an agent behave like a QA engineer using the product, not like a backend curl script. + +The primary path is: + +```text +developer intent -> lbs test plan -> agent controls browser -> UI result + console + logs -> report/assets +``` + +## Rules + +1. Browser/UI interaction is the source of truth for product QA cases. +2. A backend API or curl response is never enough to mark a UI case passed. +3. API/curl/log checks are allowed as diagnostics after a UI path is attempted or when debugging environment readiness. +4. A case passes only when the user-visible UI result is correct. +5. The agent should inspect browser console/network output when available. +6. If screenshot or vision capability is available, the agent should check for blank pages, overlap, hidden actions, broken layout, and error toasts. +7. If no visual model is available, use DOM/accessibility snapshots and console output instead. +8. New stable UI paths should be added as `cases/*.yaml`. +9. New recurring failure modes should be added as `troubleshooting/*.yaml`. +10. Secrets, tokens, API keys, and localStorage token values must never be printed. + +## Command Semantics + +`lbs` manages assets and produces plans. It does not replace the agent's browser-control ability. + +```bash +bin/lbs test plan pipeline-debug-chat +``` + +This command outputs: + +- environment variables to use +- required skills +- browser steps +- UI/console/visual/log checks +- diagnostic options +- related troubleshooting patterns +- report template + +The active agent then executes the plan with Computer Use, Playwright MCP, or another available browser-control tool. + +## Diagnostics + +Diagnostics can include: + +- `bin/lbs env doctor` +- browser console/network inspection +- backend logs +- targeted API/curl checks + +Diagnostics answer "where did it fail?" They do not replace "did the user-visible UI work?" diff --git a/skills/qa-agent-docs/qa-agent/04-black-box-e2e-roadmap.md b/skills/qa-agent-docs/qa-agent/04-black-box-e2e-roadmap.md new file mode 100644 index 000000000..4d8652519 --- /dev/null +++ b/skills/qa-agent-docs/qa-agent/04-black-box-e2e-roadmap.md @@ -0,0 +1,299 @@ +# 黑盒 E2E QA 路线图 + +## 定位 + +LangBot 有大量外部依赖:模型供应商、plugin runtime、浏览器登录态、 +marketplace/network、RAG engine、sandbox backend、平台适配器等。单测仍然有价值, +但这个 QA 方向当前不优先解决 LangBot core 的单测覆盖率问题,因为重 mock 往往不能 +真实代表产品路径。 + +`langbot-skills` 当前目标是让黑盒 E2E 测试变得可执行、可沉淀、可复用: + +```text +开发者测试意图 +-> 复用或新增 case +-> agent 通过浏览器执行 +-> UI + console + network + log 证据 +-> report +-> 反哺 case / troubleshooting +``` + +这是面向开发者的 QA 资产库。开发者可以让 agent 测一个 feature;如果路径稳定, +就把路径正规化为 case,让下一个开发者或 QA agent 继续复用。 + +## 非目标 + +- 这一阶段不优先建设 LangBot core 单测覆盖率。 +- 不把 API/curl 作为 WebUI 行为的通过标准。 +- 不要求每个 case 都能进 CI。 +- 不在 report 和日志守卫有用之前急着做完整 browser runner。 +- 不把外部 provider、OAuth、marketplace 抖动直接判成产品失败,除非证据明确。 + +## 当前状态 + +仓库已经具备第一层基础设施: + +- `skills/.env` 和 `skills/.env.local` 管理测试环境; +- `langbot-env-setup`、`langbot-testing`、`langbot-plugin-dev` 等 skill; +- `skills/langbot-testing/cases` 下的结构化 case; +- `skills/langbot-testing/troubleshooting` 下的结构化故障资产; +- RAG、多模态、plugin、MCP 等 fixture; +- `bin/lbs validate`、`bin/lbs index`、`bin/lbs case`、`bin/lbs trouble`、 + `bin/lbs test plan`、`bin/lbs test start`、`bin/lbs test report`。 + +所以当前已经不是“先把路径写进 Markdown”的阶段,而是进入“让每次运行有证据、 +有报告、能沉淀”的阶段。 + +## 测试模型 + +UI case 只有在用户可见行为正确时才能通过。辅助证据必须解释同一次运行。 + +通过一个 UI case 的最低证据: + +- 用户可见的成功信号,例如 bot 回复、provider 保存成功、文件上传完成、plugin 页面渲染; +- 没有意外 browser console error; +- 相关时间窗口内没有意外后端/runtime 错误; +- 有截图、DOM snapshot 或同等视觉/结构证据,如果当前 agent 能获取; +- API/curl 只在解释同一条 UI 路径时作为诊断证据。 + +失败报告需要保留足够信息,让开发者能复现或分流: + +- case id 和实际测试 URL; +- 使用的 browser path; +- 最后可见 UI 状态; +- console/network 症状; +- 相关后端/前端日志; +- 匹配到的 troubleshooting id; +- 这是产品失败、环境问题、外部依赖抖动,还是证据不足。 + +## 结果词汇 + +统一使用这些结果: + +- `pass`:UI 行为正确,辅助证据干净。 +- `fail`:UI 行为错误,或同一次运行的 console/log 出现意外产品错误。 +- `blocked`:缺登录、缺 provider credentials、服务未启动等原因导致目标路径没有跑起来。 +- `env_issue`:失败在目标行为之外,例如 proxy、OAuth、provider quota、marketplace outage、 + 本地服务启动问题。 +- `flaky`:同一环境下结果不稳定,进入门禁前需要先稳定。 + +做 merge/release 判断时,`env_issue` 和 `blocked` 不能算产品通过。 + +## 路线图 + +### Phase 0:对齐文档 + +目标:明确当前黑盒 E2E 方向。 + +交付物: + +- `docs/qa-agent/README.md` 文档状态导航; +- 本路线图; +- 给旧规划文档加状态说明。 + +完成标准: + +- 新贡献者不用通读所有旧文档,也能知道当前重点。 + +### Phase 1:Test Report MVP + +状态:已有第一版。 + +目标:让每次 agent browser 测试都有一致报告格式,即使 browser 执行还没自动化。 + +建议命令: + +```bash +bin/lbs test start +bin/lbs test report --output reports/-.md +``` + +MVP 行为: + +- 读取 case 和关联 troubleshooting; +- 生成 Markdown report 模板; +- 生成 run handoff,固定本次测试的 start timestamp 和推荐 report command; +- 写入脱敏后的环境摘要; +- 提供 `pass/fail/blocked/env_issue/flaky` 结果选项; +- 包含 UI result、console errors、network symptoms、logs、screenshots、 + diagnostics、matched troubleshooting、assets to update 等 section; +- 支持 `--json`,输出机器可读报告。 + +第一版已经是 report generator,不急着做自动判定。先把 evidence 收集格式统一起来, +再做自动化更稳。 + +完成标准: + +- agent 可以先跑 `lbs test start `,用它给出的时间窗口执行浏览器路径, + 然后按固定格式填写 report,不需要每次重新发明报告结构。 + +### Phase 2:日志守卫 MVP + +状态:已有第一版文件扫描。 + +目标:捕获 UI 不一定明显展示的 runtime 问题。 + +日志守卫应集成进 `lbs test report`,不要发展成独立后端 API 测试框架。 + +建议命令形态: + +```bash +bin/lbs test report \ + --backend-log /path/to/backend.log \ + --frontend-log /path/to/frontend.log \ + --console-log /path/to/console.log \ + --evidence-dir reports/evidence/ \ + --since "2026-05-21T10:30:00+08:00" \ + --tail-lines 2000 \ + --output reports/-.md +``` + +MVP 行为: + +- 默认从 `LANGBOT_REPO/data/logs/` 扫描最新 `langbot-*.log`; +- 支持 agent 显式提供 backend、frontend、console 日志文件; +- 支持读取 evidence 目录下的 `automation-result.json`,把浏览器自动化脚本结论纳入报告; +- 支持 `lbs test result` 为人工/agent browser 运行写入标准 `result.json`,供 suite 聚合; +- 支持 `--since` 和 `--tail-lines`,避免历史日志污染本次报告; +- 检测默认非预期模式,例如 `Traceback`、未 await coroutine、unclosed client/connector、 + `KeyError`、`TypeError`、`AttributeError`、明显 secret 泄露; +- 匹配 case 声明的 `success_patterns` 和 `failure_patterns`; +- 匹配已知 troubleshooting,先支持 `plugin-runtime-timeout` 和 `proxy-env-mismatch`; +- 只有 case 明确声明时,才允许 expected failure; +- 将发现分类为 fail、warning、matched troubleshooting、ignored expected issue; +- 永远不打印 secret 值。 + +完成标准: + +- 至少 `pipeline-debug-chat` 能生成包含日志摘要和 troubleshooting 匹配结果的 report。 + +### Phase 3:Case 元数据加固 + +状态:已有第一版。 + +目标:让 case 更容易选择、执行和晋级。 + +字段逐步补充,保持向后兼容: + +```yaml +priority: p0 | p1 | p2 +risk: low | medium | high +ci_eligible: false +preconditions: + - "Authenticated browser profile is available." +setup: + - "Start LangBot backend and frontend." +cleanup: + - "Remove temporary provider, plugin, or knowledge base if created." +expected_failures: [] +success_patterns: + - "Conversation(0) Streaming completed" +failure_patterns: + - "Action invoke_llm_stream call timed out" +evidence: + required: + - ui + - console + - backend_log +``` + +当前实现采用扁平字段 `evidence_required`,避免轻量 YAML 解析器在 case 文件里承载嵌套结构。 +`bin/lbs validate` 会校验 `priority`、`risk`、`ci_eligible`、`evidence_required`、 +`automation` 脚本路径、case 关联 skill 和 troubleshooting 交叉引用。`bin/lbs case list` +支持 `--json`、`--type`、`--area`、`--tag`、`--priority`、`--risk`、`--automation`、`--ci` +、`--ready` 和 `--machine-ready` 过滤,方便 agent 快速选择测试集。 +`env_any` 和 `automation_env_any` 用于表达 URL-or-name 这类 one-of 输入,避免把可替代变量误判为全部必填。 + +当前也有 `skills//suites/*.yaml` 和 `bin/lbs suite plan `,用于组织常跑测试集, +例如 `core-smoke`、`local-agent-gate` 和 +`agent-runner-release-gate`。发布门禁使用 `agent-runner-release-preflight` +先分类配置 blockers 和 runtime env issues,再运行较重的浏览器 Debug Chat case。 +依赖 fixture 的 case 可以在浏览器执行前先跑 `bin/lbs fixture check`,检查 +`fixtures/fixtures.json` 登记的 deterministic 文件、plugin 包和本地测试 server 是否存在。 +`bin/lbs suite start ` 会生成 suite run id、suite evidence root、per-case evidence 目录、 +`suite-start.json`/`suite-start.md` handoff 文件和 per-case evidence 命令; +浏览器自动化脚本会写入 `automation-result.json`,供 `bin/lbs test report` 展示原始自动化结论; +`bin/lbs test result ` 会在人工/agent browser case 完成后写入最终 `result.json`; +`bin/lbs suite report --evidence-dir ` 会聚合各 case 的 `result.json`,并且 +不会把缺少 required evidence 的 `pass` 当作 suite 通过。 +Runner 专用 Debug Chat case 通过 `automation_pipeline_url_env` 和 +`automation_pipeline_name_env` 绑定专用 pipeline 变量,避免 local-agent、Codex 或 +Claude Code case 误用通用 `LANGBOT_PIPELINE_URL` 后产生假阳性。 +Debug Chat case 还可以通过 `automation_stream_output` 固定流式或非流式发送路径。 +多模态 Debug Chat case 可以通过 `automation_image_base64_fixture` 复用 deterministic 图片 fixture。 +`test plan` 和 `suite plan` 会输出 readiness,让 agent 在执行浏览器前就看到缺失的 env、 +自动化变量、fixture,以及需要人工确认的 `manual_check` 前置条件。 + +完成标准: + +- `lbs case list` 或后续 filter 能回答“smoke 跑哪些”、“哪些适合 CI”、 + “哪些需要真实 provider credentials”。 + +### Phase 4:开发者沉淀流程 + +目标:开发者让 agent 测新 feature 后,稳定路径不会丢在聊天记录里。 + +流程: + +1. 开发者要求 agent 通过浏览器测试某个 feature。 +2. agent 先按 UI 主路径探索。 +3. agent 用 `lbs test start` 固定运行窗口,再用 `lbs test report` 写报告。 +4. 如果路径稳定,agent 新增或更新 case。 +5. 如果出现可复用故障,agent 新增或更新 troubleshooting。 +6. agent 跑 `bin/lbs validate` 和 `bin/lbs index`。 + +完成标准: + +- feature QA 的结果能进入资产库,而不是只留在一次对话里。 + +### Phase 5:选择性浏览器自动化 + +状态:已有第一版 `test run` 入口和两个 Playwright 脚本。 + +目标:只自动化少量稳定、值得重复跑的黑盒路径。 + +建议顺序: + +1. `webui-login-state` +2. `pipeline-debug-chat` +3. `local-agent-basic-debug-chat` +4. `local-agent-rag-debug-chat` +5. 一个基于 deterministic fixture 的 plugin 或 MCP smoke path + +执行策略: + +- 继续把 Computer Use 或 Playwright MCP 作为默认交互路径; +- 只给稳定、确定性的路径补直接 Playwright script; +- 保存 screenshots、console logs、trace/video; +- flaky 或强依赖真实 credentials 的 provider case 暂时不要进 CI。 + +当前已经绑定: + +- `webui-login-state` -> `scripts/e2e/webui-login-state.mjs` +- `pipeline-debug-chat` -> `scripts/e2e/pipeline-debug-chat.mjs` + +第一版自动化先产出 `reports/evidence//` 下的 console、network、screenshot 和 +result JSON。真实执行后仍要用 `lbs test report --since ... --console-log ...` 做日志守卫和 +最终报告。开发期间可以先用 `bin/lbs test run --dry-run` 检查命令和 evidence 路径。 +Debug Chat 类脚本应复用 `scripts/e2e/lib/debug-chat.mjs`,避免重复实现 visible response leaf +判断和已知失败信号分类。 + +完成标准: + +- 小规模 smoke subset 可以不靠人工决定每一步点击;更大的资产库仍然服务于人工/agent + 驱动的探索式 E2E。 + +## 下一批动工切片 + +在做 browser runner 之前,继续做这些: + +1. 等 LangBot 当前开发状态稳定后,用一次真实 `pipeline-debug-chat` 跑通 + `test start -> test run -> test report -> test result -> suite report`,产出 sample report。 +2. 只给 smoke/local-agent 首批 case 补必要元数据。 +3. 继续补日志守卫规则,尤其是 WebSocket、plugin runtime、provider streaming、前端 + chunk/rendering failure。 +4. 约定 report 产物目录、截图和 console/network 导出的命名方式。 +5. 再评估是否开始给 `webui-login-state` 和 `pipeline-debug-chat` 做直接 Playwright + 自动化。 + +这样 infra 会立刻有用,同时保留后续自动化 browser execution 的空间。 diff --git a/skills/qa-agent-docs/qa-agent/README.md b/skills/qa-agent-docs/qa-agent/README.md new file mode 100644 index 000000000..e8443450d --- /dev/null +++ b/skills/qa-agent-docs/qa-agent/README.md @@ -0,0 +1,46 @@ +# LangBot QA Agent 文档导航 + +这个目录记录 `langbot-skills` 当前的 QA 方向和后续建设顺序。 + +## 当前判断 + +当前重点是 LangBot 的黑盒 E2E QA,不是 LangBot core 的单测覆盖率建设。 + +`langbot-skills` 要帮助开发者和 QA agent 做接近人工测试的 WebUI 验证: + +- 打开真实 LangBot WebUI; +- 按用户路径点击和输入; +- 检查用户可见的 UI 结果; +- 查看 console、network、截图、后端和前端日志; +- 输出可复用的测试报告; +- 把稳定 feature 路径沉淀为 case; +- 把重复故障沉淀为 troubleshooting。 + +API 和 curl 只做诊断。它们可以解释失败原因,但不能让一个 UI case 通过。 + +## 文档状态 + +| 文档 | 状态 | 用途 | +| --- | --- | --- | +| `04-black-box-e2e-roadmap.md` | 当前主路线图 | 决定下一步建设什么。 | +| `03-agent-browser-qa-principles.md` | 当前原则文档 | 定义 browser-first QA 的通过标准。 | +| `02-log-guard-plan.md` | 当前活跃设计 | 设计 `lbs test report` 里的日志守卫。 | +| `../user-guide.md` | 当前使用手册 | 开发者日常使用。 | +| `00-technology-options.md` | 背景文档 | 选择 Computer Use、Playwright MCP 或未来直接 Playwright。 | +| `01-qa-agent-harness-plan.md` | 历史规划,部分过时 | 解释最初分层和目录设计;使用前先看状态说明。 | + +## 已过时的点 + +`01-qa-agent-harness-plan.md` 还保留早期规划状态。现在结构化 cases、 +结构化 troubleshooting、`validate`、`index`、`lbs test plan` 都已经落地。 + +已经补上第一版 `lbs test start`、`lbs test run`、`lbs test report` 和日志守卫文件扫描。 +`webui-login-state`、`pipeline-debug-chat` 已经绑定直接 Playwright 自动化脚本。后续重点是: + +- 报告 evidence 字段继续打磨; +- case success/failure signal 和日志守卫规则继续补充; +- 报告产物和 evidence 约定; +- 等 LangBot 当前开发状态稳定后跑真实 sample report。 + +不要再把旧阶段列表当成当前 source of truth。后续排序以 +`04-black-box-e2e-roadmap.md` 为准。 diff --git a/skills/qa-agent-docs/user-guide.md b/skills/qa-agent-docs/user-guide.md new file mode 100644 index 000000000..f19beeb97 --- /dev/null +++ b/skills/qa-agent-docs/user-guide.md @@ -0,0 +1,521 @@ +# LangBot Skills 用户使用手册 + +## 这个仓库解决什么 + +`langbot-skills` 是给 agent 使用的 LangBot 测试资产库。开发者 clone 后,可以让 Codex、Claude Code、Computer Use 或 Playwright MCP 复用已有环境配置、测试路径和故障知识,像 QA 一样操作 LangBot WebUI。 + +核心目标: + +- 不让下一个 agent 窗口从头探索登录、模型配置、pipeline 调试。 +- 把稳定 UI 测试路径沉淀为 case。 +- 把常见故障沉淀为 troubleshooting。 +- 让 agent 优先通过浏览器点击验证产品行为。 +- API/curl/log 只作为诊断手段,不作为 UI case 通过标准。 + +## 快速开始 + +1. Clone 仓库。 + +2. 检查本地默认变量: + +```bash +bin/lbs env show +``` + +默认变量在: + +```text +skills/.env +``` + +本机专用覆盖写到: + +```text +skills/.env.local +``` + +它会覆盖 `skills/.env` 中的同名变量,并且不应该提交。 +`skills/.env` 是共享默认值,不应写入本机绝对路径、浏览器 profile、provider key 或其他凭据。 +新机器建议从模板开始: + +```bash +cp skills/.env.example skills/.env.local +``` + +常用变量: + +```text +LANGBOT_FRONTEND_URL +LANGBOT_BACKEND_URL +LANGBOT_DEV_FRONTEND_URL +LANGBOT_REPO +LANGBOT_WEB_REPO +LANGBOT_BROWSER_PROFILE +``` + +3. 检查环境是否就绪: + +```bash +bin/lbs env doctor +bin/lbs fixture check +``` + +`env doctor` 会检查 URL、路径、代理变量等。代理变量是可选项;只有大小写代理变量互相冲突时才会报错。失败不一定代表仓库坏了,通常说明本地 LangBot 没启动、代理不一致或浏览器 profile 不存在。 +`fixture check` 会检查仓库内测试 fixture 是否存在,例如 MCP stdio server、RAG 文档、多模态图片、qa-plugin-smoke 包和 QA AgentRunner 包。它也会校验 `.lbpkg` 是 zip 包,并检查 QA AgentRunner fixture 的入口文件未漂移。 + +4. 查看已有测试 case: + +```bash +bin/lbs case list +bin/lbs case list --json --priority p0 --automation +bin/lbs case list --ready +bin/lbs case list --machine-ready +bin/lbs suite list +bin/lbs suite plan core-smoke +bin/lbs suite plan agent-runner-release-gate +bin/lbs suite start core-smoke +bin/lbs suite start core-smoke --run-id core-smoke-local --evidence-dir reports/evidence/core-smoke-local +``` + +`case list` 支持按 `--type`、`--area`、`--tag`、`--priority`、`--risk`、`--automation` +、`--ci`、`--ready` 和 `--machine-ready` 过滤。`--ready` 只显示没有缺机器输入且没有人工前置条件的 case; +`--machine-ready` 过滤掉缺机器输入的 case,但保留 `manual-check`,表示执行前还要确认前置条件。需要交给 agent 自动选择测试集时,优先使用 `--json`, +其中包含 `priority`、`risk`、`ci_eligible`、`automation`、`evidence_required` 以及 +env/automation/fixture/manual readiness。 +Case metadata 中的 `env` 和 `automation_env` 表示全部必填;URL 或 name 这类二选一输入会放在 +`env_any` 或 `automation_env_any`,readiness 只要求组合里至少一个变量有值。 + +如果要跑一组已沉淀的测试路径,优先使用 suite。Suite 位于 `skills//suites/*.yaml`, +只负责组织 case,不改变 UI/browser 作为通过标准的原则。 +`suite plan` 会聚合 readiness:缺环境变量、缺自动化变量、缺 fixture 或需要 +`manual_check` 时,会在执行前标出受影响的 case。`manual_check` 不是产品通过, +它表示机器配置已满足但 agent 必须先确认 case 里的 `preconditions` 或 `setup`。 +Runner externalization 发布判断使用 `agent-runner-release-gate`。先跑 +`agent-runner-release-preflight`,把缺 pipeline、runner id 错误、插件未安装这类 +`blocked`,以及 provider key、Box、插件运行时这类 `env_issue` 分开,再执行较重的 +浏览器 Debug Chat case。 + +5. 生成 agent 执行计划: + +```bash +bin/lbs test plan pipeline-debug-chat +``` + +然后把计划交给当前 agent 执行。agent 应使用 Computer Use、Playwright MCP 或其他浏览器控制能力去操作 UI。 +`test plan` 中的 Environment、Automation Readiness、Fixture Readiness 和 Manual +Readiness 是执行前门禁;如果 readiness 缺失,应先补配置或将本次 case 标记为 +`blocked`。如果状态是 `manual_check`,先确认 `preconditions` 和 `setup`,再开始 UI +执行。不要把后续 curl/API 诊断当成 UI case 通过。 + +## 推荐使用方式 + +### 冒烟测试 + +你可以直接对 agent 说: + +```text +帮我跑一下 LangBot 新前端 smoke test。 +``` + +agent 应该: + +- 读 `skills/.env` +- 优先查看 `bin/lbs suite plan core-smoke`,或查找 `type: smoke` 的 cases +- 生成 test plan +- 用浏览器执行 UI 操作 +- 检查 console、截图、后端日志 +- 输出简短 QA 报告 + +### Runner Externalization 发布门禁 + +你可以直接对 agent 说: + +```text +按 agent-runner release gate 跑完整矩阵,先做 preflight,再跑浏览器 case,并把 blocked/env_issue/fail 分开。 +``` + +agent 应该先查看 `skills/langbot-testing/references/agent-runner-release-gate.md`, +再执行: + +```bash +bin/lbs test recommend +bin/lbs suite plan agent-runner-release-gate +bin/lbs test run agent-runner-release-preflight --dry-run +bin/lbs suite start agent-runner-release-gate --run-id agent-runner-release-local --evidence-dir reports/evidence/agent-runner-release-local +``` + +`test recommend` 输出的 run 命令默认带 `--dry-run`;确认 readiness 和 `manual_check` 前置条件后,再去掉 `--dry-run` 执行。 + +完成所有 case 后,用: + +```bash +bin/lbs suite report agent-runner-release-gate --evidence-dir reports/evidence/agent-runner-release-local +``` + +没有最终 `result.json`、缺 required evidence、或把 `blocked`/`env_issue` 当成 pass, +都不能算发布门禁通过。 + +### 新 Feature 测试 + +你可以说: + +```text +我改了 provider 页面,帮我测一下 DeepSeek provider 添加、测试、绑定 pipeline 是否正常。 +``` + +agent 应该: + +- 查找相关 case 和 reference +- 如果没有稳定路径,先探索 UI +- 用浏览器执行真实交互 +- 失败时用日志/API 辅助定位 +- 稳定后新增或更新 case/reference +- 新故障沉淀为 troubleshooting + +### 定点排错 + +你可以说: + +```text +Debug Chat 点了没回复,帮我查是前端问题还是后端问题。 +``` + +agent 应该: + +- 先通过 UI 复现 +- 看 console/network +- 看后端日志 +- 必要时用 API/curl 做诊断 +- 匹配 troubleshooting +- 给出修复建议或直接修复 + +## 重要原则 + +这些原则固定在: + +```text +docs/qa-agent/03-agent-browser-qa-principles.md +``` + +简化版: + +- UI/browser 是测试主路径。 +- API/curl/log 只做诊断。 +- 后端接口成功不等于 UI case 通过。 +- case 通过必须以用户可见 UI 结果为准。 +- 有视觉能力时应检查截图。 +- 没有视觉能力时用 DOM/accessibility snapshot 和 console。 +- 不要打印 token、API key、OAuth secret 或 localStorage token 值。 + +## 规划文档 + +如果要判断下一步建设什么,先看: + +```text +docs/qa-agent/README.md +docs/qa-agent/04-black-box-e2e-roadmap.md +``` + +`01-qa-agent-harness-plan.md` 是早期规划,部分内容已经被当前实现和路线图替代。 + +## 常用命令 + +### 环境 + +```bash +bin/lbs env show +bin/lbs env show --json +bin/lbs env doctor +bin/lbs fixture list +bin/lbs fixture check +bin/lbs fixture check --json +``` + +`env show` 和 `env doctor` 默认会对 token、API key、password、secret 以及 URL basic auth +做脱敏。不要把 `.env.local` 里的原始凭据复制进测试报告。 + +### Skill 和索引 + +```bash +bin/lbs list +bin/lbs validate +bin/lbs index --check +bin/lbs index +``` + +### Case + +```bash +bin/lbs case list +bin/lbs case list --type smoke +bin/lbs case list --json --priority p1 --tag local-agent +bin/lbs case list --ready +bin/lbs case list --machine-ready +bin/lbs case show pipeline-debug-chat +bin/lbs case new my-feature --title "My Feature Works" +``` + +### Suite + +```bash +bin/lbs suite list +bin/lbs suite list --json --priority p1 +bin/lbs suite show local-agent-gate +bin/lbs suite plan core-smoke +bin/lbs suite plan local-agent-gate --json +bin/lbs suite start core-smoke +bin/lbs suite start core-smoke --run-id core-smoke-local --evidence-dir reports/evidence/core-smoke-local +bin/lbs suite run core-smoke --dry-run --json +bin/lbs suite run core-smoke --run-id core-smoke-local --evidence-dir reports/evidence/core-smoke-local +bin/lbs suite start core-smoke --json +bin/lbs suite report core-smoke --evidence-dir reports/evidence/ +bin/lbs suite report core-smoke --evidence-dir reports/evidence/ --json +bin/lbs suite new my-feature-gate --title "My Feature Gate" +``` + +`suite start` 不直接控制浏览器。它生成统一 run id、suite evidence root、每个 case 的 evidence +目录、`suite-start.json`/`suite-start.md` handoff 文件,以及每个 case 的 `test run`、`test report` +和 `test result` 命令模板。需要固定路径时,使用 `--run-id` 和 `--evidence-dir`。 +`suite run --dry-run --json` 只预览 planned/skipped case,不创建 evidence,也不执行 automation。 +`suite run` 会顺序执行 suite 中已有 automation、机器 readiness 已满足且不需要 `manual_check` 的 case,并在最后聚合 `suite report`。 +缺 env、automation env 或 fixture 的 case 默认会跳过;确实要强制执行时,加 `--include-not-ready`。 +确认前置条件后,才用 `--include-manual-check` 执行这类 case。 +所有 case 执行完并写入最终 `result.json` 后, +`suite report` 会读取各 case evidence 目录并汇总为 `pass`、`fail`、`blocked`、`env_issue`、 +`flaky`、`incomplete` 等状态。`pass` 必须声明已经收集 case 的全部 required evidence; +否则 suite 会保持 `incomplete`,避免把缺证据的运行误判成通过。 + +### Test Plan + +```bash +bin/lbs test plan pipeline-debug-chat +bin/lbs test plan pipeline-debug-chat --json +``` + +### Test Start + +```bash +bin/lbs test start pipeline-debug-chat +bin/lbs test start pipeline-debug-chat --json +``` + +`test start` 用于 agent 开始一次浏览器测试前记录 run id、开始时间和推荐 report 命令。 +它会把 `--since ""` 写进后续报告命令,减少历史日志污染本次判断。 +如果 case 绑定了自动化脚本,输出里也会包含 `test run` 命令和 evidence 目录。 + +### Test Automation + +```bash +bin/lbs test run webui-login-state --dry-run +bin/lbs test run pipeline-debug-chat --dry-run +bin/lbs test run webui-login-state --run-id login-smoke --output reports/evidence/login-smoke +bin/lbs test run pipeline-debug-chat --run-id pipeline-smoke --output reports/evidence/pipeline-smoke +``` + +查看当前所有带自动化脚本的 case: + +```bash +bin/lbs case list --automation +bin/lbs case list --json --automation +``` + +当前自动化覆盖包括登录态、通用 Pipeline Debug Chat、local-agent runner 的基础回复、 +PromptPreProcessing、RAG、plugin tool、MCP stdio tool、非流式、多模态和 RAG+多模态路径。 +不要在文档里手工维护静态 case 清单;以 `case list --automation` 和 suite 定义为准。 + +自动化脚本位于 `scripts/e2e/`。它们会保存: + +- `console.log` +- `network.log` +- `screenshot.png` +- `automation-result.json` + +新增 Debug Chat 类自动化时,优先复用 `scripts/e2e/lib/debug-chat.mjs` 中的 pipeline 打开、 +prompt 发送、visible response leaf 判断和失败信号分类,不要在新脚本里复制 DOM 扫描逻辑。 + +脚本需要本地安装 Playwright 后才能真正执行: + +```bash +npm install +npx playwright install chromium +``` + +`pipeline-debug-chat` 通用自动化建议配置 `LANGBOT_PIPELINE_URL`。如果没有 direct URL, +脚本会尝试通过 `LANGBOT_PIPELINE_NAME` 从 Pipelines 页面进入目标 pipeline。两者都没有时, +该自动化会返回 `blocked`,不会伪造通过。 + +Runner 专用 case 不应复用通用 pipeline 变量。Local Agent、Codex AgentRunner 和 +Claude Code AgentRunner 这类 case 会通过 `automation_pipeline_url_env` / +`automation_pipeline_name_env` 映射到 case-specific env,例如 +`LANGBOT_LOCAL_AGENT_PIPELINE_URL`。这些 case 如果缺少专用变量,会返回 `blocked`, +不会退回到 `LANGBOT_PIPELINE_URL`,避免跑错 pipeline 后产生假阳性。 +如果 case 声明了 `setup_automation`,只有 `bin/lbs test run ` 的真实执行路径会先运行这些 setup; +`test plan`、`suite plan`、`case list` 和 `--dry-run` 只展示它们,不会修改本地环境。 +setup 可以是 `case:` 或仓库内 `node:scripts/... --flag`,每一步证据会写到主 evidence 目录下的 +`setup/` 子目录。setup 失败时主 automation 不会继续执行;setup 写入 `.env.local` 后,主 automation +会重新读取环境。用 `setup_provides_env` 声明 setup 会生成的变量,可以让 readiness 正确显示机器可准备状态。 +如果 Debug Chat case 需要固定流式/非流式路径,可以在 case 中设置 +`automation_stream_output: "1"` 或 `"0"`,脚本会在发送消息前切换 Debug Chat 的 Stream 控件。 +如果 case 需要上传图片,可以设置 `automation_image_base64_fixture` 指向仓库内的 base64 PNG fixture, +脚本会在 evidence 目录写出临时 PNG 并通过 Debug Chat 上传控件发送。 +`bin/lbs test plan --json` 和 `bin/lbs suite plan --json` +都会显示这些专用变量是否已配置。 + +### Test Report 和日志守卫 + +```bash +bin/lbs test report pipeline-debug-chat +bin/lbs test report pipeline-debug-chat --output reports/pipeline-debug-chat.md +bin/lbs test report pipeline-debug-chat \ + --backend-log /path/to/backend.log \ + --frontend-log /path/to/frontend.log \ + --console-log /path/to/console.log +bin/lbs test report pipeline-debug-chat --evidence-dir reports/evidence/pipeline-smoke +bin/lbs test report pipeline-debug-chat --backend-log /path/to/backend.log --json +bin/lbs test report pipeline-debug-chat --since "2026-05-21T10:30:00+08:00" +bin/lbs test report pipeline-debug-chat --tail-lines 2000 +bin/lbs test report pipeline-debug-chat --since "2026-05-21T10:30:00+08:00" --tail-lines 2000 +``` + +`test report` 会生成报告模板,并默认从 `LANGBOT_REPO/data/logs/` 自动选择最新的 +`langbot-*.log` 作为 LangBot 后端日志扫描。也可以用 `--backend-log` 覆盖,或用 +`--no-auto-log` 只生成模板。 + +如果提供 `--evidence-dir`,或 `--console-log` 指向 `reports/evidence//console.log`, +报告会优先读取同目录的 `automation-result.json`,并展示自动化脚本的 `status`、`reason`、 +起止时间和目标 URL。 + +日志守卫会扫描常见错误、secret 泄露风险、case 声明的 success/failure patterns,以及已知 +troubleshooting pattern。它不控制浏览器,也不替代 UI 通过判断。`success_patterns` +命中会作为通过证据写入 `success_signals`;声明了 success pattern 但本次扫描窗口没有命中, +会给 warning;`failure_patterns` 命中会让本次日志守卫 fail。 + +建议在执行浏览器 case 前记录当前时间,然后在报告阶段使用 `--since`。如果只想快速看 +最近日志,可以使用 `--tail-lines`。 + +### Runtime Log Guard + +如果还没有进入某个具体 UI case,只是想观察 LangBot 后端日志,可以直接使用 `log` +命令。它和 `test report` 使用同一套扫描器、secret 脱敏、troubleshooting pattern 和 +case success/failure pattern。 + +```bash +bin/lbs log scan --tail-lines 300 +bin/lbs log scan --case pipeline-debug-chat --since "2026-05-21T10:30:00+08:00" +bin/lbs log scan --backend-log /path/to/langbot.log --json +bin/lbs log scan --failure-pattern "runner.tool_loop_error|Action invoke_llm_stream call timed out" --strict +``` + +`log scan` 默认从 `LANGBOT_REPO/data/logs/` 自动选择最新的 `langbot-*.log`。传入 +`--case ` 后,会额外应用该 case 声明的 `success_patterns`、`failure_patterns` +和 related troubleshooting。默认用于观察,返回码保持 0;加 `--strict` 后,`fail` 或 +`env_issue` 会返回非 0,适合脚本门禁。 + +运行期观察可以用 `watch`: + +```bash +bin/lbs log watch --case pipeline-debug-chat +bin/lbs log watch --backend-log /path/to/langbot.log --interval-ms 500 +bin/lbs log watch --duration-ms 30000 --strict --json +``` + +`log watch` 默认从启动时的文件末尾开始,只观察新追加的日志;加 `--from-start` 可从文件开头扫。 +它会实时打印新命中的 findings 和 success signals。为了避免当前历史日志噪声影响观察,默认不因 +异常返回非 0;加 `--strict` 后,退出时如果看到 `fail` 或 `env_issue` 会返回非 0。 + +给一次 QA 运行包日志窗口时,用 `guard start/stop`: + +```bash +bin/lbs log guard start --run-id local-debug --case pipeline-debug-chat +# 执行浏览器或手工测试 +bin/lbs log guard stop --run-id local-debug +``` + +`start` 会在 `reports/log-guards/.json` 记录起始时间、case 和当前后端日志路径; +`stop` 会用 start/stop 时间作为扫描窗口,生成 `reports/log-guards/.md`,并默认按 +strict guard 返回码处理。临时只想收集报告、不想让命令失败,可以加 `--no-strict`。 + +当前 LangBot core 日志还不是完全结构化日志,runtime guard 主要依赖时间窗口和文本 pattern。 +已支持 ISO 时间戳和 LangBot 当前的 `[MM-DD HH:mm:ss.SSS]` 前缀;没有时间戳的连续行会跟随上一条 +带时间戳的日志块。如果后续 core 能提供稳定 request id、conversation id、plugin action id 或 +JSON log field,guard 可以从“时间窗口 + 文本匹配”升级为更精确的关联分析。 + +### Test Result + +```bash +bin/lbs test result pipeline-debug-chat \ + --result pass \ + --reason "Debug Chat returned OK and the report log guard was clean." \ + --evidence-dir reports/evidence/pipeline-smoke \ + --started-at "2026-05-21T10:30:00+08:00" \ + --evidence ui,screenshot,console,backend_log +``` + +`test result` 用于把一次人工/agent browser 运行的最终判断写成标准 `result.json`, +供 `suite report` 聚合。它不会替代 UI 测试:如果写 `--result pass`,`--evidence` +必须覆盖该 case 的 `evidence_required`,否则命令会失败。自动化脚本写 +`automation-result.json`;如果 case 还要求 backend log、API diagnostic 或 filesystem +evidence,agent 需要在报告和诊断完成后再用 `test result` 写最终 `result.json`。 + +### Troubleshooting + +```bash +bin/lbs trouble list langbot-testing +bin/lbs trouble show plugin-runtime-timeout +bin/lbs trouble search runtime +bin/lbs trouble add langbot-testing --title "..." --symptom "..." --cause "..." --fix "..." +``` + +## 目录说明 + +```text +skills/ + .env # 共享默认变量 + langbot-env-setup/ # 环境、浏览器、登录态、代理 + langbot-testing/ # WebUI / provider / pipeline 测试 +schemas/ # 结构化资产 schema +src/ # lbs TypeScript 源码 +bin/ # lbs 入口 +docs/ # 设计文档和用户手册 +AGENTS.md # agent 维护协议 +``` + +## 添加一个新测试路径 + +1. 先让 agent 通过浏览器探索并执行路径。 +2. 稳定后创建 case: + +```bash +bin/lbs case new provider-xxx --title "Provider XXX can be configured" --area provider --type provider +``` + +3. 编辑生成的 `cases/*.yaml`,补充真实步骤、检查点和 troubleshooting。 + +4. 校验: + +```bash +bin/lbs validate +bin/lbs index --check +bin/lbs index +``` + +## 添加一个新故障 + +```bash +bin/lbs trouble add langbot-testing \ + --title "Plugin runtime actions time out" \ + --symptom "Debug Chat shows Agent runner temporarily unavailable" \ + --cause "Old plugin runtime survived backend restart" \ + --fix "Stop old runtime processes and restart LangBot" +``` + +然后编辑生成的 YAML,补充 `patterns`、`related_cases` 和验证方式。 + +## 当前边界 + +- `lbs test plan` 只生成测试计划,不直接控制浏览器。 +- `lbs test report` 生成报告,默认扫描最新 LangBot 后端日志;也可扫描显式提供的 + backend/frontend/console 日志文件。 +- 真正的 UI 操作由当前 agent 的浏览器能力执行。 +- `env doctor` 是 readiness check,不是产品测试。 +- `curl/API` 是诊断工具,不是主要测试路径。 diff --git a/skills/schemas/README.md b/skills/schemas/README.md new file mode 100644 index 000000000..0b06d9edd --- /dev/null +++ b/skills/schemas/README.md @@ -0,0 +1,59 @@ +# Schemas + +这个目录存放 LangBot skills 结构化资产的 JSON Schema。 + +它们不是测试脚本,也不会执行浏览器动作。它们的作用是定义 agent 和维护者后续新增资产时应该遵守的文件结构。 + +## 文件说明 + +- `skills//fixtures/fixtures.json` + 不是 JSON Schema,但由 `bin/lbs validate` 校验。 + 它登记 deterministic fixture 文件、类型和关联 case,供 `bin/lbs fixture check` 做 readiness 检查。 + +- `case.schema.json` + 约束 `skills//cases/*.yaml` 的格式。 + Case 描述 agent-browser 或 probe QA 路径,包括前置条件、步骤、检查点、诊断手段和关联故障。 + +- `suite.schema.json` + 约束 `skills//suites/*.yaml` 的格式。 + Suite 只组织 case 集合,用于 smoke、regression 或 release gate 等测试入口。 + +- `troubleshooting.schema.json` + 约束 `skills//troubleshooting/*.yaml` 的格式。 + Troubleshooting 条目描述症状、日志/错误模式、可能原因、修复步骤和验证信号。 + +- `skill-index.schema.json` + 约束生成文件 `skills.index.json` 的格式。 + 这个索引用于让 agent 快速发现已有 skills、references、cases、suites 和 troubleshooting。 + +- `reports/evidence//result.json` + 不是 catalog schema,而是执行期最终裁定产物,由 `bin/lbs test result` 写入。 + `suite report` 读取其中的 `status`、`reason`、起止时间和 `evidence_collected`, + 并用 `evidence_missing` 防止缺证据的 `pass` 被当作完整通过。 + +- `reports/evidence//automation-result.json` + 不是 catalog schema,而是浏览器自动化脚本的原始运行结论,供 `bin/lbs test report` + 展示和推断日志扫描窗口。 + +## 为什么需要 schemas + +Schemas 是基础设施护栏: + +- 防止 case、suite 和 troubleshooting 随着增长变得格式混乱 +- 让 `bin/lbs validate` 能发现缺字段和错误结构 +- 为未来编辑器提示和 CI 校验留接口 +- 帮助 agent 新增资产时知道应该写哪些字段 + +## 当前校验方式 + +`bin/lbs validate` 做轻量、schema 对齐的校验,不引入额外依赖。它会检查必填字段、 +枚举值、boolean 字段、重复列表项、automation 脚本存在性,以及 case、suite、skill、 +troubleshooting 之间的交叉引用。这里的 schema 仍是格式契约;如果未来引入正式 JSON +Schema validator,应继续保持这些本地交叉引用检查。 + +Case 里的 `env` / `automation_env` 表示所有列出的变量都需要配置。遇到二选一输入时, +使用 `env_any` / `automation_env_any`,每一项写成 `LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME` +这类 one-of 组合,避免 agent 因为只配置了 URL 或 name 其中之一而误判未就绪。 +`setup` 和 `preconditions` 是人工确认项,会让 readiness 进入 `manual_check`; +`setup_automation` 是 `test run` 可以自动执行的准备步骤,配合 `setup_provides_env` +声明它会生成的机器变量。 diff --git a/skills/schemas/case.schema.json b/skills/schemas/case.schema.json new file mode 100644 index 000000000..46601142a --- /dev/null +++ b/skills/schemas/case.schema.json @@ -0,0 +1,326 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://langbot.app/schemas/langbot-skills/case.schema.json", + "title": "LangBot Skill Test Case", + "type": "object", + "required": [ + "id", + "title", + "mode", + "area", + "type", + "priority", + "risk", + "ci_eligible", + "tags", + "skills", + "steps", + "checks", + "evidence_required" + ], + "allOf": [ + { + "if": { + "properties": { + "mode": { "const": "agent-browser" } + } + }, + "then": { + "required": ["env"] + } + } + ], + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "title": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": ["agent-browser", "probe"] + }, + "area": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "smoke", + "regression", + "feature", + "provider", + "exploratory", + "contract", + "performance", + "reliability", + "chaos", + "security" + ] + }, + "priority": { + "type": "string", + "enum": ["p0", "p1", "p2"] + }, + "risk": { + "type": "string", + "enum": ["low", "medium", "high"] + }, + "ci_eligible": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { "type": "string" } + }, + "skills": { + "type": "array", + "items": { "type": "string" } + }, + "env": { + "type": "array", + "items": { "type": "string" } + }, + "env_any": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*(\\|[A-Z][A-Z0-9_]*)+$" + } + }, + "steps": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "checks": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "evidence_required": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "ui", + "screenshot", + "console", + "network", + "backend_log", + "frontend_log", + "api_diagnostic", + "filesystem", + "metrics", + "trace", + "profile", + "resource_log" + ] + }, + "minItems": 1 + }, + "preconditions": { + "type": "array", + "items": { "type": "string" } + }, + "setup": { + "type": "array", + "items": { "type": "string" } + }, + "setup_automation": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?:case:[a-z0-9][a-z0-9_-]*|node:scripts/[A-Za-z0-9_./-]+\\.(?:mjs|js|ts)(?:\\s+--[A-Za-z0-9][A-Za-z0-9_-]*(?:=[A-Za-z0-9_./:@-]+)?)*)$" + } + }, + "setup_provides_env": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + } + }, + "cleanup": { + "type": "array", + "items": { "type": "string" } + }, + "diagnostics": { + "type": "array", + "items": { "type": "string" } + }, + "automation": { + "type": "string" + }, + "automation_env": { + "type": "array", + "items": { "type": "string" } + }, + "automation_env_any": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*(\\|[A-Z][A-Z0-9_]*)+$" + } + }, + "automation_prompt": { + "type": "string" + }, + "automation_prompts_json": { + "type": "string" + }, + "automation_expected_text": { + "type": "string" + }, + "automation_response_timeout_ms": { + "type": "string" + }, + "automation_stream_output": { + "type": "string", + "enum": ["0", "1", "false", "true"] + }, + "automation_image_base64_fixture": { + "type": "string" + }, + "automation_runner_config_patch_json": { + "type": "string" + }, + "automation_restore_runner_config": { + "type": "string", + "enum": ["0", "1", "false", "true"] + }, + "automation_expected_runner_id": { + "type": "string" + }, + "automation_reset_debug_chat": { + "type": "string", + "enum": ["0", "1", "false", "true"] + }, + "automation_debug_chat_session_type": { + "type": "string", + "enum": ["person", "group"] + }, + "automation_debug_chat_response_p95_ms": { + "type": "string" + }, + "automation_debug_chat_max_error_rate": { + "type": "string" + }, + "automation_debug_chat_load_requests": { + "type": "string" + }, + "automation_debug_chat_load_concurrency": { + "type": "string" + }, + "automation_debug_chat_load_timeout_ms": { + "type": "string" + }, + "automation_debug_chat_load_response_p95_ms": { + "type": "string" + }, + "automation_debug_chat_load_first_response_p95_ms": { + "type": "string" + }, + "automation_debug_chat_load_max_error_rate": { + "type": "string" + }, + "automation_debug_chat_load_min_error_rate": { + "type": "string" + }, + "automation_debug_chat_load_min_error_count": { + "type": "string" + }, + "automation_debug_chat_load_min_ok_count": { + "type": "string" + }, + "automation_debug_chat_load_min_provider_fault_count": { + "type": "string" + }, + "automation_debug_chat_load_expected_prefix": { + "type": "string" + }, + "automation_debug_chat_load_prompt_template": { + "type": "string" + }, + "automation_debug_chat_load_stream": { + "type": "string", + "enum": ["0", "1", "false", "true"] + }, + "automation_debug_chat_load_reset": { + "type": "string", + "enum": ["0", "1", "false", "true"] + }, + "automation_debug_chat_load_fail_on_final_mismatch": { + "type": "string", + "enum": ["0", "1", "false", "true"] + }, + "automation_fake_provider_response_text": { + "type": "string" + }, + "automation_fake_provider_first_token_delay_ms": { + "type": "string" + }, + "automation_fake_provider_chunk_delay_ms": { + "type": "string" + }, + "automation_fake_provider_chunk_count": { + "type": "string" + }, + "automation_fake_provider_fail_first_n": { + "type": "string" + }, + "automation_fake_provider_fail_every_n": { + "type": "string" + }, + "automation_fake_provider_fault_status": { + "type": "string" + }, + "automation_fake_provider_fail_after_first_chunk": { + "type": "string", + "enum": ["0", "1", "false", "true"] + }, + "automation_fake_provider_dynamic_response": { + "type": "string", + "enum": ["0", "1", "false", "true"] + }, + "automation_filesystem_checks_json": { + "type": "string" + }, + "metrics_thresholds_json": { + "type": "string" + }, + "load_profile_json": { + "type": "string" + }, + "fault_model_json": { + "type": "string" + }, + "automation_pipeline_url_env": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "automation_pipeline_name_env": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "success_patterns": { + "type": "array", + "items": { "type": "string" } + }, + "failure_patterns": { + "type": "array", + "items": { "type": "string" } + }, + "expected_failures": { + "type": "array", + "items": { "type": "string" } + }, + "troubleshooting": { + "type": "array", + "items": { "type": "string" } + } + } +} diff --git a/skills/schemas/skill-index.schema.json b/skills/schemas/skill-index.schema.json new file mode 100644 index 000000000..1a080c55e --- /dev/null +++ b/skills/schemas/skill-index.schema.json @@ -0,0 +1,147 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://langbot.app/schemas/langbot-skills/skill-index.schema.json", + "title": "LangBot Skills Index", + "type": "object", + "required": ["generated_by", "skills"], + "additionalProperties": false, + "properties": { + "generated_by": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "object", + "required": [ + "directory", + "name", + "description", + "references", + "cases", + "case_summaries", + "suites", + "suite_summaries", + "fixtures", + "troubleshooting", + "troubleshooting_summaries" + ], + "additionalProperties": false, + "properties": { + "directory": { "type": "string" }, + "name": { "type": "string" }, + "description": { "type": "string" }, + "references": { + "type": "array", + "items": { "type": "string" } + }, + "cases": { + "type": "array", + "items": { "type": "string" } + }, + "case_summaries": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "mode", "area", "type", "priority", "risk", "ci_eligible", "tags", "automation", "setup_automation", "setup_provides_env", "evidence_required"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "mode": { "type": "string", "enum": ["agent-browser", "probe"] }, + "area": { "type": "string" }, + "type": { "type": "string" }, + "priority": { "type": "string" }, + "risk": { "type": "string" }, + "ci_eligible": { "type": "boolean" }, + "tags": { + "type": "array", + "items": { "type": "string" } + }, + "automation": { "type": "string" }, + "setup_automation": { + "type": "array", + "items": { "type": "string" } + }, + "setup_provides_env": { + "type": "array", + "items": { "type": "string" } + }, + "evidence_required": { + "type": "array", + "items": { "type": "string" } + } + } + } + }, + "suites": { + "type": "array", + "items": { "type": "string" } + }, + "suite_summaries": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "description", "type", "priority", "tags", "cases"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "type": { "type": "string" }, + "priority": { "type": "string" }, + "tags": { + "type": "array", + "items": { "type": "string" } + }, + "cases": { + "type": "array", + "items": { "type": "string" } + } + } + } + }, + "fixtures": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "kind", "path", "related_cases"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "kind": { "type": "string" }, + "path": { "type": "string" }, + "related_cases": { + "type": "array", + "items": { "type": "string" } + } + } + } + }, + "troubleshooting": { + "type": "array", + "items": { "type": "string" } + }, + "troubleshooting_summaries": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "category", "related_cases"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "category": { "type": "string" }, + "related_cases": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + } + } + } +} diff --git a/skills/schemas/suite.schema.json b/skills/schemas/suite.schema.json new file mode 100644 index 000000000..4f3fa7c7a --- /dev/null +++ b/skills/schemas/suite.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://langbot.app/schemas/langbot-skills/suite.schema.json", + "title": "LangBot Skill Test Suite", + "type": "object", + "required": ["id", "title", "description", "type", "priority", "tags", "cases"], + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "smoke", + "regression", + "release_gate", + "exploratory", + "contract", + "performance", + "reliability", + "chaos", + "security" + ] + }, + "priority": { + "type": "string", + "enum": ["p0", "p1", "p2"] + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "cases": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + } + } +} diff --git a/skills/schemas/troubleshooting.schema.json b/skills/schemas/troubleshooting.schema.json new file mode 100644 index 000000000..1005f5e04 --- /dev/null +++ b/skills/schemas/troubleshooting.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://langbot.app/schemas/langbot-skills/troubleshooting.schema.json", + "title": "LangBot Skill Troubleshooting Entry", + "type": "object", + "required": ["id", "title", "symptoms", "patterns", "likely_causes", "fix_steps", "verification"], + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "title": { + "type": "string" + }, + "date": { + "type": "string" + }, + "category": { + "type": "string", + "enum": ["product", "env_issue", "external_dependency", "blocked", "flaky"] + }, + "symptoms": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "patterns": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "likely_causes": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "fix_steps": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "verification": { + "type": "string" + }, + "related_cases": { + "type": "array", + "items": { "type": "string" } + } + } +} diff --git a/skills/scripts/bootstrap-lbs.mjs b/skills/scripts/bootstrap-lbs.mjs new file mode 100755 index 000000000..07233a7c3 --- /dev/null +++ b/skills/scripts/bootstrap-lbs.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const binDir = resolve(root, "bin"); +const lbsPath = resolve(binDir, "lbs"); +const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "", + 'SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"', + 'exec node "$SCRIPT_DIR/../src/lbs.ts" "$@"', + "", +].join("\n"); + +await mkdir(binDir, { recursive: true }); + +let current = ""; +try { + current = await readFile(lbsPath, "utf8"); +} catch { + // Missing wrapper is the normal first-run path. +} + +if (current !== wrapper) { + await writeFile(lbsPath, wrapper, "utf8"); + await chmod(lbsPath, 0o755); +} diff --git a/skills/scripts/e2e/agent-runner-release-preflight.mjs b/skills/scripts/e2e/agent-runner-release-preflight.mjs new file mode 100755 index 000000000..6ac69f2f1 --- /dev/null +++ b/skills/scripts/e2e/agent-runner-release-preflight.mjs @@ -0,0 +1,476 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + createBrowser, + ensureEvidence, + evidencePaths, + exitCode, + localIsoWithOffset, + safeScreenshot, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +function loadEnvDefaults(path) { + if (!existsSync(path)) return; + for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const sep = line.indexOf("="); + if (sep === -1) continue; + const key = line.slice(0, sep).trim(); + if (env[key]) continue; + env[key] = line.slice(sep + 1).trim().replace(/^["']|["']$/g, ""); + } +} + +function boolFromEnv(value, defaultValue) { + if (value === undefined || value === "") return defaultValue; + if (/^(0|false|no|off)$/i.test(value)) return false; + if (/^(1|true|yes|on)$/i.test(value)) return true; + return defaultValue; +} + +function firstEnv(...keys) { + for (const key of keys) { + if (env[key]) return env[key]; + } + return ""; +} + +function redactMessage(text) { + return String(text ?? "") + .replace(/\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]") + .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]") + .replace(/(api[_-]?key|authorization|credential|jwt|oauth|password|secret|token)\s*[:=]\s*["']?[^"',\s]+/gi, "$1=[redacted]"); +} + +function isEnvironmentError(message) { + return /Playwright is not installed|LANGBOT_FRONTEND_URL|LANGBOT_BACKEND_URL|ERR_CONNECTION_REFUSED|ECONNREFUSED|net::ERR_|fetch failed|timed out/i + .test(message); +} + +loadEnvDefaults("skills/.env"); +loadEnvDefaults("skills/.env.local"); + +const caseId = env.LBS_CASE_ID || "agent-runner-release-preflight"; +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const frontendUrl = env.LANGBOT_FRONTEND_URL || backendUrl; +const testModels = boolFromEnv(env.LANGBOT_PREFLIGHT_TEST_MODELS, true); +const requireVision = boolFromEnv(env.LANGBOT_PREFLIGHT_REQUIRE_VISION, true); +const diagnosticPath = resolve(paths.evidenceDir, "api-diagnostic.json"); +const startedAt = new Date(); + +const targets = [ + { + id: "local-agent", + expected_runner_id: "plugin:langbot/local-agent/default", + pipeline_url: firstEnv("LANGBOT_LOCAL_AGENT_PIPELINE_URL"), + pipeline_name: firstEnv("LANGBOT_LOCAL_AGENT_PIPELINE_NAME"), + require_func_call_model: true, + require_vision_model: requireVision, + require_langbot_mcp: false, + }, + { + id: "acp-agent-runner", + expected_runner_id: "plugin:langbot/acp-agent-runner/default", + pipeline_url: firstEnv("LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL", "LANGBOT_AGENT_RUNNER_PIPELINE_URL"), + pipeline_name: firstEnv("LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME", "LANGBOT_AGENT_RUNNER_PIPELINE_NAME"), + require_func_call_model: false, + require_vision_model: false, + }, +]; + +let browser; +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + frontend_url: frontendUrl, + backend_url: backendUrl, + test_models: testModels, + require_vision_model: requireVision, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + api_diagnostic_json: diagnosticPath, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console", "network", "api_diagnostic"], +}; + +async function run() { + if (!backendUrl || !frontendUrl) { + result.status = "env_issue"; + result.reason = "LANGBOT_FRONTEND_URL and LANGBOT_BACKEND_URL must be configured."; + return; + } + + browser = await createBrowser(paths); + const { page } = browser; + await page.goto(frontendUrl, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + + const diagnostic = await page.evaluate(async ({ backendUrl, targets, testModels }) => { + const blockers = []; + const envIssues = []; + const warnings = []; + const checks = []; + + const addCheck = (name, status, detail = {}) => { + checks.push({ name, status, ...detail }); + if (status === "blocked") blockers.push({ name, ...detail }); + if (status === "env_issue") envIssues.push({ name, ...detail }); + }; + const safeMessage = (value) => String(value ?? "") + .replace(/\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]") + .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]") + .replace(/(api[_-]?key|authorization|credential|jwt|oauth|password|secret|token)\s*[:=]\s*["']?[^"',\s]+/gi, "$1=[redacted]"); + + const token = localStorage.getItem("token"); + if (!token) { + addCheck("browser-auth", "blocked", { reason: "Browser profile has no localStorage token." }); + return { authenticated: false, blockers, env_issues: envIssues, warnings, checks }; + } + + const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + const getJson = async (path) => { + const response = await fetch(`${backendUrl}${path}`, { headers }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }; + const postJson = async (path, body) => { + const response = await fetch(`${backendUrl}${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }; + + const tokenCheck = await getJson("/api/v1/user/check-token"); + addCheck( + "browser-auth", + tokenCheck.status < 400 && (tokenCheck.json.code ?? 0) === 0 ? "pass" : "blocked", + { http_status: tokenCheck.status, code: tokenCheck.json.code ?? null, reason: safeMessage(tokenCheck.json.msg || "") }, + ); + + const systemInfo = await getJson("/api/v1/system/info"); + addCheck( + "backend-system-info", + systemInfo.status < 400 ? "pass" : "env_issue", + { + http_status: systemInfo.status, + version: systemInfo.json.data?.version || systemInfo.json.data?.system?.version || "", + }, + ); + + const pluginSystem = await getJson("/api/v1/system/status/plugin-system"); + addCheck( + "plugin-system", + pluginSystem.status < 400 && (pluginSystem.json.code ?? 0) === 0 ? "pass" : "env_issue", + { + http_status: pluginSystem.status, + code: pluginSystem.json.code ?? null, + status: pluginSystem.json.data?.status || pluginSystem.json.data?.state || "", + reason: safeMessage(pluginSystem.json.msg || ""), + }, + ); + + const boxStatus = await getJson("/api/v1/box/status"); + addCheck( + "box-runtime", + boxStatus.status < 400 && (boxStatus.json.code ?? 0) === 0 ? "pass" : "env_issue", + { + http_status: boxStatus.status, + code: boxStatus.json.code ?? null, + status: boxStatus.json.data?.status || "", + backend: boxStatus.json.data?.backend || "", + reason: safeMessage(boxStatus.json.msg || ""), + }, + ); + + const plugins = await getJson("/api/v1/plugins"); + const installedPluginIds = (plugins.json.data?.plugins || []) + .map((plugin) => { + const metadata = plugin.manifest?.manifest?.metadata || plugin.manifest?.metadata || plugin.metadata || {}; + return metadata.author && metadata.name ? `${metadata.author}/${metadata.name}` : ""; + }) + .filter(Boolean); + const requiredPlugins = ["langbot/local-agent", "langbot/acp-agent-runner", "qa/plugin-smoke"]; + const pluginPresence = Object.fromEntries(requiredPlugins.map((id) => [id, installedPluginIds.includes(id)])); + for (const [id, present] of Object.entries(pluginPresence)) { + addCheck(`plugin:${id}`, present ? "pass" : "blocked", { plugin_id: id, reason: present ? "" : "Required plugin is not listed by /api/v1/plugins." }); + } + + const tools = await getJson("/api/v1/tools"); + const toolNames = (tools.json.data?.tools || []) + .map((tool) => tool.name || tool.tool_name || tool.function?.name || "") + .filter(Boolean) + .sort(); + addCheck( + "tool:qa_plugin_echo", + toolNames.includes("qa_plugin_echo") ? "pass" : "blocked", + { reason: toolNames.includes("qa_plugin_echo") ? "" : "qa-plugin-smoke tool qa_plugin_echo is not exposed through /api/v1/tools." }, + ); + if (!toolNames.includes("qa_mcp_echo")) { + warnings.push({ + name: "tool:qa_mcp_echo", + reason: "qa_mcp_echo is not currently exposed. This is acceptable before mcp-stdio-register, but mcp-stdio-tool-call must run after registration.", + }); + } + + const modelResponse = await getJson("/api/v1/provider/models/llm"); + const models = (modelResponse.json.data?.models || []).map((model) => ({ + uuid: model.uuid, + name: model.name, + abilities: Array.isArray(model.abilities) ? model.abilities : [], + provider_uuid: model.provider_uuid || model.provider?.uuid || "", + provider_name: model.provider_name || model.provider?.name || "", + requester: model.requester || model.provider?.requester || "", + })); + addCheck( + "llm-model-list", + modelResponse.status < 400 && (modelResponse.json.code ?? 0) === 0 ? "pass" : "env_issue", + { http_status: modelResponse.status, model_count: models.length, reason: safeMessage(modelResponse.json.msg || "") }, + ); + const modelById = new Map(models.map((model) => [model.uuid, model])); + + const pipelineList = await getJson("/api/v1/pipelines"); + const pipelines = pipelineList.json.data?.pipelines || []; + addCheck( + "pipeline-list", + pipelineList.status < 400 && (pipelineList.json.code ?? 0) === 0 ? "pass" : "blocked", + { http_status: pipelineList.status, pipeline_count: pipelines.length, reason: safeMessage(pipelineList.json.msg || "") }, + ); + + const resolvedPipelines = []; + const modelTested = new Set(); + for (const target of targets) { + let pipelineId = ""; + let matchedBy = ""; + if (target.pipeline_url) { + try { + pipelineId = new URL(target.pipeline_url).searchParams.get("id") || ""; + matchedBy = pipelineId ? "url" : ""; + } catch { + pipelineId = ""; + } + } + if (!pipelineId && target.pipeline_name) { + const match = pipelines.find((pipeline) => pipeline.name === target.pipeline_name); + if (match) { + pipelineId = match.uuid; + matchedBy = "name"; + } + } + if (!pipelineId) { + addCheck(`pipeline:${target.id}`, "blocked", { + target: target.id, + reason: "Required pipeline env is missing or could not resolve to a pipeline id.", + }); + continue; + } + + const response = await getJson(`/api/v1/pipelines/${encodeURIComponent(pipelineId)}`); + const pipeline = response.json.data?.pipeline; + if (response.status >= 400 || !pipeline) { + addCheck(`pipeline:${target.id}`, "blocked", { + target: target.id, + pipeline_id: pipelineId, + http_status: response.status, + reason: safeMessage(response.json.msg || "Could not load pipeline."), + }); + continue; + } + + const config = pipeline.config || {}; + const aiConfig = config.ai && typeof config.ai === "object" ? config.ai : {}; + const runner = aiConfig.runner && typeof aiConfig.runner === "object" ? aiConfig.runner : {}; + const runnerId = runner.id || runner.runner || ""; + const runnerConfigs = aiConfig.runner_config && typeof aiConfig.runner_config === "object" ? aiConfig.runner_config : {}; + const runnerConfig = runnerConfigs[runnerId] && typeof runnerConfigs[runnerId] === "object" ? runnerConfigs[runnerId] : {}; + const pipelineSummary = { + target: target.id, + pipeline_id: pipelineId, + pipeline_name: pipeline.name, + matched_by: matchedBy, + runner_id: runnerId, + expected_runner_id: target.expected_runner_id, + runner_config_keys: Object.keys(runnerConfig).sort(), + }; + resolvedPipelines.push(pipelineSummary); + + addCheck( + `pipeline:${target.id}:runner`, + runnerId === target.expected_runner_id ? "pass" : "blocked", + { + ...pipelineSummary, + reason: runnerId === target.expected_runner_id ? "" : `Expected ${target.expected_runner_id}, got ${runnerId || ""}.`, + }, + ); + + if (target.require_func_call_model || target.require_vision_model || (testModels && target.id === "local-agent")) { + const modelConfig = runnerConfig.model; + const primaryModelId = typeof modelConfig === "string" + ? modelConfig + : modelConfig && typeof modelConfig === "object" + ? modelConfig.primary || "" + : ""; + if (!primaryModelId) { + addCheck(`pipeline:${target.id}:primary-model`, "blocked", { + ...pipelineSummary, + reason: "Local-agent runner config has no primary model.", + }); + continue; + } + const model = modelById.get(primaryModelId); + if (!model) { + addCheck(`pipeline:${target.id}:primary-model`, "blocked", { + ...pipelineSummary, + model_uuid: primaryModelId, + reason: "Primary model is not listed by /api/v1/provider/models/llm.", + }); + continue; + } + addCheck(`pipeline:${target.id}:primary-model`, "pass", { + ...pipelineSummary, + model: { + uuid: model.uuid, + name: model.name, + abilities: model.abilities, + provider_name: model.provider_name, + requester: model.requester, + }, + }); + if (target.require_func_call_model) { + addCheck( + `pipeline:${target.id}:func-call-model`, + model.abilities.includes("func_call") ? "pass" : "env_issue", + { + model_uuid: model.uuid, + model_name: model.name, + abilities: model.abilities, + reason: model.abilities.includes("func_call") ? "" : "Release gate includes tool-call cases; the local-agent primary model must advertise func_call.", + }, + ); + } + if (target.require_vision_model) { + addCheck( + `pipeline:${target.id}:vision-model`, + model.abilities.includes("vision") ? "pass" : "env_issue", + { + model_uuid: model.uuid, + model_name: model.name, + abilities: model.abilities, + reason: model.abilities.includes("vision") ? "" : "Release gate includes multimodal cases; the local-agent primary model must advertise vision.", + }, + ); + } + if (testModels && !modelTested.has(model.uuid)) { + modelTested.add(model.uuid); + const modelTest = await postJson(`/api/v1/provider/models/llm/${encodeURIComponent(model.uuid)}/test`, { extra_args: {} }); + const passed = modelTest.status < 400 && (modelTest.json.code ?? 0) === 0; + addCheck( + `model-test:${model.name}`, + passed ? "pass" : "env_issue", + { + model_uuid: model.uuid, + model_name: model.name, + http_status: modelTest.status, + code: modelTest.json.code ?? null, + reason: passed ? "" : safeMessage(modelTest.json.msg || modelTest.json.message || "Model test failed."), + }, + ); + } + } + } + + return { + authenticated: true, + blockers, + env_issues: envIssues, + warnings, + checks, + resolved_pipelines: resolvedPipelines, + tools: { + required: ["qa_plugin_echo"], + optional_before_register: ["qa_mcp_echo"], + present: toolNames.filter((name) => ["qa_plugin_echo", "qa_mcp_echo"].includes(name)), + }, + models, + }; + }, { backendUrl, targets, testModels }); + + diagnostic.blockers = (diagnostic.blockers || []).map((item) => ({ ...item, reason: redactMessage(item.reason || "") })); + diagnostic.env_issues = (diagnostic.env_issues || []).map((item) => ({ ...item, reason: redactMessage(item.reason || "") })); + await writeFile(diagnosticPath, `${JSON.stringify(diagnostic, null, 2)}\n`, "utf8"); + await safeScreenshot(page, paths.screenshot); + + const blockers = diagnostic.blockers || []; + const envIssues = diagnostic.env_issues || []; + if (blockers.length > 0) { + result.status = "blocked"; + result.reason = `Preflight blocked: ${blockers.map((item) => item.name).join(", ")}`; + } else if (envIssues.length > 0) { + result.status = "env_issue"; + result.reason = `Preflight environment issue: ${envIssues.map((item) => item.name).join(", ")}`; + } else { + result.status = "pass"; + result.reason = "Release gate preflight passed: auth, plugin runtime, required pipelines, runner ids, tools, and local-agent model checks are ready."; + } + result.check_count = Array.isArray(diagnostic.checks) ? diagnostic.checks.length : 0; + result.warning_count = Array.isArray(diagnostic.warnings) ? diagnostic.warnings.length : 0; +} + +try { + await run(); +} catch (error) { + const message = redactMessage(error instanceof Error ? error.message : String(error)); + result.status = isEnvironmentError(message) ? "env_issue" : "fail"; + result.reason = message; + await writeFile(diagnosticPath, `${JSON.stringify({ + authenticated: false, + blockers: [], + env_issues: result.status === "env_issue" ? [{ name: "preflight-runtime", reason: message }] : [], + warnings: [], + checks: [ + { + name: "preflight-runtime", + status: result.status, + reason: message, + }, + ], + }, null, 2)}\n`, "utf8").catch(() => {}); +} finally { + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/scripts/e2e/ensure-acp-agent-runner-pipeline.mjs b/skills/scripts/e2e/ensure-acp-agent-runner-pipeline.mjs new file mode 100755 index 000000000..1278fcd1b --- /dev/null +++ b/skills/scripts/e2e/ensure-acp-agent-runner-pipeline.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + apiJson, + ensureEvidence, + evidencePaths, + loadEnvFiles, + resetAndAuthLocalUser, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const RUNNER_ID = "plugin:langbot/acp-agent-runner/default"; +const DEFAULT_PIPELINE_NAME = "Agent QA ACP Claude Debug Chat"; +const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; +const caseId = "ensure-acp-agent-runner-pipeline"; + +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const writeEnv = process.argv.includes("--write-env"); +const frontendUrl = env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const pipelineName = env.LANGBOT_E2E_CREATE_PIPELINE_NAME || env.LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME || DEFAULT_PIPELINE_NAME; +const sshTarget = env.LANGBOT_ACP_AGENT_RUNNER_SSH_TARGET || "yhh@101.34.71.12"; +const sshConnectTimeout = env.LANGBOT_ACP_AGENT_RUNNER_SSH_CONNECT_TIMEOUT || "8"; +const sshPort = env.LANGBOT_ACP_AGENT_RUNNER_SSH_PORT || "22"; +const sshIdentityFile = env.LANGBOT_ACP_AGENT_RUNNER_SSH_IDENTITY_FILE || ""; +const sshExtraOptions = env.LANGBOT_ACP_AGENT_RUNNER_SSH_EXTRA_OPTIONS || ""; +const remoteWorkspace = env.LANGBOT_ACP_AGENT_RUNNER_REMOTE_WORKSPACE || "/home/yhh/langbot-e2e/acp-workspace"; +const envLocalPath = resolve("skills/.env.local"); + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + frontend_url: frontendUrl, + backend_url: backendUrl, + pipeline_name: pipelineName, + pipeline_id: "", + pipeline_url: "", + runner_id: RUNNER_ID, + ssh_target: sshTarget, + ssh_port: sshPort, + remote_workspace: remoteWorkspace, + wrote_env: false, + auth: null, + evidence: { + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["api_diagnostic"], +}; + +try { + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + + const user = env.LANGBOT_E2E_LOGIN_USER || ""; + const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD; + if (!user) { + throw new Error("LANGBOT_E2E_LOGIN_USER is required so this setup can create/update the pipeline via backend API."); + } + + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + result.auth = { + source: "local_recovery_login", + user, + backend_token_check: auth.check, + }; + + const runnerConfig = { + provider: "claude-code", + location: "remote-ssh", + workspace: remoteWorkspace, + "ssh-target": sshTarget, + "ssh-port": Number.parseInt(sshPort, 10), + "ssh-identity-file": sshIdentityFile, + "ssh-connect-timeout": Number.parseInt(sshConnectTimeout, 10), + "ssh-extra-options": sshExtraOptions, + "langbot-assets-enabled": true, + "mcp-bridge-request-timeout": 90, + "reuse-session": false, + "create-session-if-missing": true, + "append-run-scope-prompt": true, + "startup-timeout": 30, + "initialize-timeout": 120, + timeout: 300, + }; + + const prepared = await ensurePipeline({ + backendUrl, + token: auth.token, + pipelineName, + runnerId: RUNNER_ID, + runnerConfig, + }); + Object.assign(result, prepared); + if (result.pipeline_id) { + result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(result.pipeline_id)}`; + } + + if (writeEnv && result.pipeline_id) { + await upsertEnvLocal(envLocalPath, { + LANGBOT_E2E_LOGIN_USER: user, + LANGBOT_ACP_AGENT_RUNNER_SSH_TARGET: sshTarget, + LANGBOT_ACP_AGENT_RUNNER_SSH_PORT: sshPort, + LANGBOT_ACP_AGENT_RUNNER_SSH_IDENTITY_FILE: sshIdentityFile, + LANGBOT_ACP_AGENT_RUNNER_SSH_EXTRA_OPTIONS: sshExtraOptions, + LANGBOT_ACP_AGENT_RUNNER_REMOTE_WORKSPACE: remoteWorkspace, + LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL: result.pipeline_url, + LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME: result.pipeline_name || pipelineName, + }); + result.wrote_env = true; + } +} catch (error) { + result.reason = result.reason || error.message; +} finally { + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); + +async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runnerConfig }) { + const pipelineList = await apiJson(backendUrl, "/api/v1/pipelines", { token }); + if (isApiFailure(pipelineList)) { + return { + status: "fail", + reason: pipelineList.json.msg || "Failed to list pipelines.", + list_status: pipelineList.status, + }; + } + + const pipelines = pipelineList.json.data?.pipelines || []; + let pipeline = pipelines.find((item) => item.name === pipelineName) || null; + let created = false; + + if (!pipeline) { + const createdResponse = await apiJson(backendUrl, "/api/v1/pipelines", { + method: "POST", + token, + body: { + name: pipelineName, + description: "Local QA pipeline for real ACP Claude AgentRunner Debug Chat smoke tests.", + emoji: "QA", + }, + }); + if (isApiFailure(createdResponse)) { + return { + status: "fail", + reason: createdResponse.json.msg || "Failed to create pipeline.", + create_status: createdResponse.status, + }; + } + const pipelineId = createdResponse.json.data?.uuid || ""; + const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipelineId)}`, { token }); + pipeline = loaded.json.data?.pipeline || null; + created = true; + } + + if (!pipeline?.uuid) { + return { + status: "fail", + reason: "Pipeline was not created or resolved.", + }; + } + + const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { token }); + if (isApiFailure(loaded) || !loaded.json.data?.pipeline) { + return { + status: "fail", + reason: loaded.json.msg || "Failed to load pipeline.", + get_status: loaded.status, + pipeline_id: pipeline.uuid, + }; + } + pipeline = loaded.json.data.pipeline; + + const config = pipeline.config && typeof pipeline.config === "object" ? pipeline.config : {}; + const ai = config.ai && typeof config.ai === "object" ? config.ai : {}; + const runnerConfigs = ai.runner_config && typeof ai.runner_config === "object" ? ai.runner_config : {}; + const updatedConfig = { + ...config, + ai: { + ...ai, + runner: { + ...(ai.runner && typeof ai.runner === "object" ? ai.runner : {}), + id: runnerId, + "expire-time": 0, + }, + runner_config: { + ...runnerConfigs, + [runnerId]: runnerConfig, + }, + }, + }; + + const updateResponse = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { + method: "PUT", + token, + body: { + name: pipelineName, + description: "Local QA pipeline for real ACP Claude AgentRunner Debug Chat smoke tests.", + emoji: "QA", + config: updatedConfig, + }, + }); + if (isApiFailure(updateResponse)) { + return { + status: "fail", + reason: updateResponse.json.msg || "Failed to update pipeline.", + update_status: updateResponse.status, + pipeline_id: pipeline.uuid, + }; + } + + return { + status: "pass", + reason: created ? "ACP AgentRunner pipeline created and configured." : "ACP AgentRunner pipeline updated.", + pipeline_id: pipeline.uuid, + pipeline_name: pipelineName, + created, + updated: true, + }; +} + +function isApiFailure(response) { + return response.status >= 400 || (response.json && response.json.code !== undefined && response.json.code !== 0); +} + +async function upsertEnvLocal(path, values) { + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch { + text = ""; + } + const lines = text.split(/\r?\n/); + const keys = new Set(Object.keys(values)); + const output = []; + for (const line of lines) { + const match = line.match(/^([A-Z][A-Z0-9_]*)=/); + if (match && keys.has(match[1])) { + output.push(`${match[1]}=${values[match[1]]}`); + keys.delete(match[1]); + } else if (line !== "" || output.length > 0) { + output.push(line); + } + } + if (keys.size > 0 && output.length > 0 && output[output.length - 1] !== "") { + output.push(""); + } + for (const key of keys) { + output.push(`${key}=${values[key]}`); + } + await writeFile(path, `${output.join("\n").replace(/\n+$/, "")}\n`, "utf8"); +} diff --git a/skills/scripts/e2e/ensure-fake-provider-cross-pipelines.mjs b/skills/scripts/e2e/ensure-fake-provider-cross-pipelines.mjs new file mode 100755 index 000000000..592a7b7f9 --- /dev/null +++ b/skills/scripts/e2e/ensure-fake-provider-cross-pipelines.mjs @@ -0,0 +1,205 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { env } from "node:process"; +import { + appendLine, + ensureEvidence, + evidencePaths, + loadEnvFiles, + redact, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = "ensure-fake-provider-cross-pipelines"; +const DEFAULT_PIPELINE_A_NAME = "LangBot QA Fake Provider Debug Chat A"; +const DEFAULT_PIPELINE_B_NAME = "LangBot QA Fake Provider Debug Chat B"; + +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const writeEnv = process.argv.includes("--write-env"); +const envLocalPath = resolve("skills/.env.local"); +const pipelineAName = env.LANGBOT_FAKE_PROVIDER_PIPELINE_A_NAME || DEFAULT_PIPELINE_A_NAME; +const pipelineBName = env.LANGBOT_FAKE_PROVIDER_PIPELINE_B_NAME || DEFAULT_PIPELINE_B_NAME; + +const result = { + source: "setup_automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + pipeline_a: { + name: pipelineAName, + id: "", + url: "", + }, + pipeline_b: { + name: pipelineBName, + id: "", + url: "", + }, + fake_provider: { + url: "", + base_url: "", + pid: null, + }, + wrote_env: false, + evidence: { + console_log: paths.consoleLog, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["api_diagnostic", "filesystem"], +}; + +try { + console.error(`[langbot-qa] configuring cross-pipeline QA fixtures: pipeline_a=\"${pipelineAName}\", pipeline_b=\"${pipelineBName}\"`); + console.error("[langbot-qa] run these fake-provider setup/probe commands serially when they share LANGBOT_FAKE_PROVIDER_URL."); + if (pipelineAName === pipelineBName) { + throw new Error("LANGBOT_FAKE_PROVIDER_PIPELINE_A_NAME and LANGBOT_FAKE_PROVIDER_PIPELINE_B_NAME must be different."); + } + + const setupA = await runPipelineSetup(pipelineAName, "A"); + const setupB = await runPipelineSetup(pipelineBName, "B"); + result.pipeline_a = { + name: setupA.pipeline_name || pipelineAName, + id: setupA.pipeline_id || "", + url: setupA.pipeline_url || "", + }; + result.pipeline_b = { + name: setupB.pipeline_name || pipelineBName, + id: setupB.pipeline_id || "", + url: setupB.pipeline_url || "", + }; + result.fake_provider = { + url: setupB.fake_provider?.url || setupA.fake_provider?.url || "", + base_url: setupB.fake_provider?.base_url || setupA.fake_provider?.base_url || "", + pid: setupB.fake_provider?.pid ?? setupA.fake_provider?.pid ?? null, + }; + + if (!result.pipeline_a.url || !result.pipeline_b.url || !result.fake_provider.url) { + throw new Error("Cross-pipeline fake provider setup did not return both pipeline URLs and provider URL."); + } + + if (writeEnv) { + await upsertEnvLocal(envLocalPath, { + LANGBOT_FAKE_PROVIDER_URL: result.fake_provider.url, + LANGBOT_FAKE_PROVIDER_BASE_URL: result.fake_provider.base_url, + LANGBOT_FAKE_PROVIDER_PID: result.fake_provider.pid ? String(result.fake_provider.pid) : "", + LANGBOT_FAKE_PROVIDER_PIPELINE_A_URL: result.pipeline_a.url, + LANGBOT_FAKE_PROVIDER_PIPELINE_A_NAME: result.pipeline_a.name, + LANGBOT_FAKE_PROVIDER_PIPELINE_B_URL: result.pipeline_b.url, + LANGBOT_FAKE_PROVIDER_PIPELINE_B_NAME: result.pipeline_b.name, + }); + result.wrote_env = true; + } + + result.status = "pass"; + result.reason = "Fake provider cross-pipeline fixtures are configured."; +} catch (error) { + result.status = looksLikeEnvIssue(error) ? "env_issue" : "fail"; + result.reason = safeReason(error.message); +} finally { + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); + +function runPipelineSetup(pipelineName, label) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(process.execPath, ["scripts/e2e/ensure-fake-provider-pipeline.mjs"], { + cwd: resolve("."), + env: { + ...env, + LANGBOT_FAKE_PROVIDER_PIPELINE_NAME: pipelineName, + LANGBOT_FAKE_PROVIDER_FIRST_TOKEN_DELAY_MS: env.LANGBOT_FAKE_PROVIDER_FIRST_TOKEN_DELAY_MS || "25", + LANGBOT_FAKE_PROVIDER_CHUNK_DELAY_MS: env.LANGBOT_FAKE_PROVIDER_CHUNK_DELAY_MS || "10", + LANGBOT_FAKE_PROVIDER_CHUNK_COUNT: env.LANGBOT_FAKE_PROVIDER_CHUNK_COUNT || "0", + LANGBOT_FAKE_PROVIDER_FAIL_FIRST_N: "0", + LANGBOT_FAKE_PROVIDER_FAIL_EVERY_N: "0", + LANGBOT_FAKE_PROVIDER_FAULT_STATUS: env.LANGBOT_FAKE_PROVIDER_FAULT_STATUS || "500", + LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK: "false", + LANGBOT_FAKE_PROVIDER_DYNAMIC_RESPONSE: "true", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + const text = chunk.toString(); + stdout += text; + appendLine(paths.consoleLog, `[setup ${label} stdout] ${text.trimEnd()}`).catch(() => {}); + }); + child.stderr.on("data", (chunk) => { + const text = chunk.toString(); + stderr += text; + appendLine(paths.consoleLog, `[setup ${label} stderr] ${text.trimEnd()}`).catch(() => {}); + }); + child.on("error", rejectPromise); + child.on("close", (code) => { + const parsed = parseJsonOutput(stdout); + if (code !== 0 || parsed.status !== "pass") { + rejectPromise(new Error(parsed.reason || stderr || `Fake provider pipeline setup ${label} exited with ${code}.`)); + return; + } + resolvePromise(parsed); + }); + }); +} + +function parseJsonOutput(text) { + const trimmed = String(text || "").trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed); + } catch { + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start >= 0 && end > start) { + try { + return JSON.parse(trimmed.slice(start, end + 1)); + } catch { + return {}; + } + } + return {}; + } +} + +async function upsertEnvLocal(path, updates) { + await mkdir(dirname(path), { recursive: true }); + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch { + text = ""; + } + const lines = text.split(/\r?\n/); + const seen = new Set(); + const next = lines.map((line) => { + const trimmed = line.trim(); + const match = trimmed.match(/^([A-Z][A-Z0-9_]*)=/); + if (!match || updates[match[1]] === undefined) return line; + seen.add(match[1]); + return `${match[1]}=${updates[match[1]]}`; + }); + for (const [key, value] of Object.entries(updates)) { + if (!seen.has(key)) next.push(`${key}=${value}`); + } + await writeFile(path, `${next.join("\n").replace(/\n+$/, "")}\n`, "utf8"); +} + +function looksLikeEnvIssue(error) { + const message = String(error?.message || error || ""); + return /fetch failed|ECONNREFUSED|ENOTFOUND|LANGBOT_.*not configured|Could not read recovery_key|Backend did not respond/i.test(message); +} + +function safeReason(value) { + return redact(String(value || "")).slice(0, 1000); +} diff --git a/skills/scripts/e2e/ensure-fake-provider-pipeline.mjs b/skills/scripts/e2e/ensure-fake-provider-pipeline.mjs new file mode 100755 index 000000000..73f2465fd --- /dev/null +++ b/skills/scripts/e2e/ensure-fake-provider-pipeline.mjs @@ -0,0 +1,635 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { open, readFile, mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { env } from "node:process"; +import { + apiJson, + ensureEvidence, + evidencePaths, + loadEnvFiles, + redact, + resetAndAuthLocalUser, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const RUNNER_ID = "local-agent"; +const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; +const DEFAULT_PIPELINE_NAME = "LangBot QA Fake Provider Debug Chat"; +const DEFAULT_PROVIDER_NAME = "LangBot QA Fake OpenAI Provider"; +const QA_RESOURCE_DESCRIPTION = "Managed by LangBot skills QA automation for controlled fake-provider Debug Chat tests. Safe to delete when local QA fixtures are no longer needed."; +const DEFAULT_MODEL_NAME = "gpt-4o-mini"; +const DEFAULT_REQUESTER = "openai-chat-completions"; + +const caseId = "ensure-fake-provider-pipeline"; + +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const writeEnv = process.argv.includes("--write-env"); +const frontendUrl = env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const envLocalPath = resolve("skills/.env.local"); +const repoRoot = resolve(env.LANGBOT_REPO || ".."); +const fakeStateDir = resolve(env.LANGBOT_FAKE_PROVIDER_STATE_DIR || resolve(repoRoot, ".qa/fake-provider")); +const fakeStatePath = resolve(fakeStateDir, "state.json"); +const fakeStdoutPath = resolve(fakeStateDir, "fake-provider.stdout.log"); +const fakeStderrPath = resolve(fakeStateDir, "fake-provider.stderr.log"); +const pipelineName = env.LANGBOT_FAKE_PROVIDER_PIPELINE_NAME || DEFAULT_PIPELINE_NAME; +const providerName = env.LANGBOT_FAKE_PROVIDER_NAME || DEFAULT_PROVIDER_NAME; +const requester = env.LANGBOT_FAKE_PROVIDER_REQUESTER || DEFAULT_REQUESTER; +const modelName = env.LANGBOT_FAKE_PROVIDER_MODEL_NAME || DEFAULT_MODEL_NAME; + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + frontend_url: frontendUrl, + backend_url: backendUrl, + fake_provider: { + url: "", + base_url: "", + pid: null, + reused: false, + config: {}, + state_file: fakeStatePath, + stdout_log: fakeStdoutPath, + stderr_log: fakeStderrPath, + }, + provider: { + uuid: "", + name: providerName, + requester, + created: false, + updated: false, + }, + model: { + uuid: "", + name: modelName, + created: false, + updated: false, + test_status: "not_run", + test_reason: "", + }, + pipeline_id: "", + pipeline_name: pipelineName, + pipeline_url: "", + created: false, + updated: false, + wrote_env: false, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["api_diagnostic", "network", "filesystem"], +}; + +try { + console.error(`[langbot-qa] configuring QA-owned fake-provider fixtures: provider=\"${providerName}\", pipeline=\"${pipelineName}\"`); + console.error("[langbot-qa] this setup may create or update local QA provider/model/pipeline resources on the selected backend."); + if (!backendUrl) { + result.status = "env_issue"; + throw new Error("LANGBOT_BACKEND_URL is not configured."); + } + if (!frontendUrl) { + result.status = "env_issue"; + throw new Error("LANGBOT_FRONTEND_URL is not configured."); + } + + const fakeProvider = await ensureFakeProvider(); + const setupConfig = await configureFakeProvider(fakeProvider.url, healthyFakeProviderConfig(), true); + result.fake_provider = { + ...result.fake_provider, + ...fakeProvider, + config: setupConfig.config || healthyFakeProviderConfig(), + }; + + const user = env.LANGBOT_E2E_LOGIN_USER || ""; + const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD; + if (!user) { + result.status = "env_issue"; + throw new Error("LANGBOT_E2E_LOGIN_USER is required so this setup can create/update the fake provider pipeline."); + } + + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + const wizard = await skipWizard({ backendUrl, token: auth.token }); + if (wizard.status !== "pass") { + result.status = "fail"; + throw new Error(wizard.reason || "Failed to mark the local QA wizard as skipped."); + } + + const provider = await ensureProvider({ + backendUrl, + token: auth.token, + name: providerName, + requester, + baseUrl: fakeProvider.base_url, + }); + result.provider = provider; + + const model = await ensureModel({ + backendUrl, + token: auth.token, + providerUuid: provider.uuid, + name: modelName, + }); + result.model = model; + + const pipeline = await ensurePipeline({ + backendUrl, + token: auth.token, + name: pipelineName, + modelUuid: model.uuid, + }); + Object.assign(result, pipeline); + result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(pipeline.pipeline_id)}`; + + const runConfig = await configureFakeProvider(fakeProvider.url, targetFakeProviderConfig(), true); + result.fake_provider.config = runConfig.config || targetFakeProviderConfig(); + + if (writeEnv) { + await upsertEnvLocal(envLocalPath, { + LANGBOT_E2E_LOGIN_USER: user, + LANGBOT_FAKE_PROVIDER_URL: fakeProvider.url, + LANGBOT_FAKE_PROVIDER_BASE_URL: fakeProvider.base_url, + LANGBOT_FAKE_PROVIDER_PID: fakeProvider.pid ? String(fakeProvider.pid) : "", + LANGBOT_FAKE_PROVIDER_PROVIDER_UUID: provider.uuid, + LANGBOT_FAKE_PROVIDER_MODEL_UUID: model.uuid, + LANGBOT_FAKE_PROVIDER_PIPELINE_URL: result.pipeline_url, + LANGBOT_FAKE_PROVIDER_PIPELINE_NAME: pipelineName, + }); + result.wrote_env = true; + } + + result.status = "pass"; + result.reason = `Fake provider pipeline is configured with ${requester}/${modelName}.`; +} catch (error) { + result.status = result.status === "env_issue" ? "env_issue" : "fail"; + result.reason = result.reason || safeReason(error.message); +} finally { + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); + +async function ensureFakeProvider() { + const envUrl = normalizeProviderRootUrl(env.LANGBOT_FAKE_PROVIDER_URL || ""); + if (envUrl && await fakeProviderHealthy(envUrl) && await fakeProviderConfigurable(envUrl)) { + return { + url: envUrl, + base_url: `${envUrl}/v1`, + pid: null, + reused: true, + }; + } + + const state = await readState(fakeStatePath); + const stateUrl = normalizeProviderRootUrl(state.url || ""); + if (stateUrl && await fakeProviderHealthy(stateUrl)) { + if (await fakeProviderConfigurable(stateUrl)) { + return { + url: stateUrl, + base_url: state.base_url || `${stateUrl}/v1`, + pid: Number.isInteger(state.pid) ? state.pid : null, + reused: true, + }; + } + if (Number.isInteger(state.pid)) await stopProcess(state.pid); + } + + await mkdir(fakeStateDir, { recursive: true }); + await writeFile(fakeStatePath, `${JSON.stringify({ status: "starting", started_at: new Date().toISOString() }, null, 2)}\n`, "utf8"); + const stdout = await open(fakeStdoutPath, "a"); + const stderr = await open(fakeStderrPath, "a"); + const scriptPath = resolve("scripts/e2e/fake-openai-provider.mjs"); + const host = env.LANGBOT_FAKE_PROVIDER_HOST || "127.0.0.1"; + const port = env.LANGBOT_FAKE_PROVIDER_PORT || "0"; + const child = spawn(process.execPath, [ + scriptPath, + `--host=${host}`, + `--port=${port}`, + `--state-file=${fakeStatePath}`, + ], { + cwd: resolve("."), + detached: true, + env: { + ...env, + LANGBOT_FAKE_PROVIDER_MODEL_NAME: modelName, + }, + stdio: ["ignore", stdout.fd, stderr.fd], + }); + child.unref(); + await stdout.close(); + await stderr.close(); + + const started = await waitForFakeProviderState(fakeStatePath, child.pid, 10_000); + if (!started.url || !await fakeProviderHealthy(started.url) || !await fakeProviderConfigurable(started.url)) { + throw new Error(`Fake provider did not become healthy. See ${fakeStderrPath}`); + } + + return { + url: started.url, + base_url: started.base_url || `${started.url}/v1`, + pid: child.pid ?? started.pid ?? null, + reused: false, + }; +} + +async function configureFakeProvider(rootUrl, config, resetRequestCount) { + const response = await fetch(`${normalizeProviderRootUrl(rootUrl)}/__qa/config`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + config, + reset_request_count: resetRequestCount, + }), + signal: AbortSignal.timeout(3000), + }); + const json = await response.json().catch(() => ({})); + if (!response.ok || json.ok !== true) { + throw new Error(`Fake provider config failed with HTTP ${response.status}.`); + } + return json; +} + +async function fakeProviderHealthy(rootUrl) { + try { + const response = await fetch(`${rootUrl.replace(/\/$/, "")}/healthz`, { + signal: AbortSignal.timeout(2000), + }); + if (!response.ok) return false; + const json = await response.json().catch(() => ({})); + return json.ok === true; + } catch { + return false; + } +} + +async function fakeProviderConfigurable(rootUrl) { + try { + const response = await fetch(`${rootUrl.replace(/\/$/, "")}/__qa/config`, { + signal: AbortSignal.timeout(2000), + }); + if (!response.ok) return false; + const json = await response.json().catch(() => ({})); + return json.ok === true && json.config && typeof json.config === "object"; + } catch { + return false; + } +} + +async function stopProcess(pid) { + try { + process.kill(pid, "SIGTERM"); + } catch { + return; + } + await sleep(500); +} + +async function waitForFakeProviderState(path, expectedPid, timeoutMs) { + const startedAt = Date.now(); + let lastState = {}; + while (Date.now() - startedAt < timeoutMs) { + const state = await readState(path); + if (state.url && (!expectedPid || state.pid === expectedPid)) return state; + lastState = state; + await sleep(150); + } + return lastState; +} + +async function readState(path) { + try { + return JSON.parse(await readFile(path, "utf8")); + } catch { + return {}; + } +} + +function normalizeProviderRootUrl(value) { + const trimmed = String(value || "").trim().replace(/\/$/, ""); + return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed; +} + +function healthyFakeProviderConfig() { + return { + response_text: "OK", + first_token_delay_ms: 25, + chunk_delay_ms: 10, + chunk_count: 0, + fault_status: 500, + fail_first_n: 0, + fail_every_n: 0, + fail_after_first_chunk: false, + dynamic_response: true, + }; +} + +function targetFakeProviderConfig() { + return { + response_text: env.LANGBOT_FAKE_PROVIDER_RESPONSE_TEXT || "OK", + first_token_delay_ms: nonNegativeInteger(env.LANGBOT_FAKE_PROVIDER_FIRST_TOKEN_DELAY_MS, 25), + chunk_delay_ms: nonNegativeInteger(env.LANGBOT_FAKE_PROVIDER_CHUNK_DELAY_MS, 10), + chunk_count: nonNegativeInteger(env.LANGBOT_FAKE_PROVIDER_CHUNK_COUNT, 0), + fault_status: httpFaultStatus(env.LANGBOT_FAKE_PROVIDER_FAULT_STATUS, 500), + fail_first_n: nonNegativeInteger(env.LANGBOT_FAKE_PROVIDER_FAIL_FIRST_N, 0), + fail_every_n: nonNegativeInteger(env.LANGBOT_FAKE_PROVIDER_FAIL_EVERY_N, 0), + fail_after_first_chunk: envBool(env.LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK, false), + dynamic_response: envBool(env.LANGBOT_FAKE_PROVIDER_DYNAMIC_RESPONSE, true), + }; +} + +async function skipWizard({ backendUrl, token }) { + const response = await apiJson(backendUrl, "/api/v1/system/wizard/completed", { + method: "POST", + token, + body: { status: "skipped" }, + }); + const ok = response.status < 400 && response.json.code === 0; + return { + status: ok ? "pass" : "fail", + http_status: response.status, + code: response.json.code ?? null, + reason: ok ? "Wizard marked skipped for local QA." : response.json.msg || "Wizard status update failed.", + }; +} + +async function ensureProvider({ backendUrl, token, name, requester, baseUrl }) { + const list = await apiJson(backendUrl, "/api/v1/provider/providers", { token }); + if (isApiFailure(list)) { + throw new Error(list.json.msg || "Failed to list providers."); + } + const providers = list.json.data?.providers || []; + const existing = providers.find((provider) => ( + provider.name === name + || (provider.requester === requester && String(provider.base_url || "").replace(/\/$/, "") === baseUrl.replace(/\/$/, "")) + )); + const body = { + name, + requester, + base_url: baseUrl, + api_keys: [env.LANGBOT_FAKE_PROVIDER_API_KEY || "langbot-fake-provider-key"], + }; + + if (existing?.uuid) { + const update = await apiJson(backendUrl, `/api/v1/provider/providers/${encodeURIComponent(existing.uuid)}`, { + method: "PUT", + token, + body, + }); + if (isApiFailure(update)) { + throw new Error(update.json.msg || "Failed to update fake provider."); + } + return { + uuid: existing.uuid, + name, + requester, + created: false, + updated: true, + }; + } + + const create = await apiJson(backendUrl, "/api/v1/provider/providers", { + method: "POST", + token, + body, + }); + const uuid = create.json.data?.uuid || ""; + if (isApiFailure(create) || !uuid) { + throw new Error(create.json.msg || "Failed to create fake provider."); + } + return { + uuid, + name, + requester, + created: true, + updated: false, + }; +} + +async function ensureModel({ backendUrl, token, providerUuid, name }) { + const list = await apiJson(backendUrl, `/api/v1/provider/models/llm?provider_uuid=${encodeURIComponent(providerUuid)}`, { token }); + if (isApiFailure(list)) { + throw new Error(list.json.msg || "Failed to list fake provider models."); + } + const models = list.json.data?.models || []; + const existing = models.find((model) => model.name === name); + const body = { + name, + provider_uuid: providerUuid, + abilities: [], + context_length: positiveInteger(env.LANGBOT_FAKE_PROVIDER_CONTEXT_LENGTH, 8192), + extra_args: {}, + prefered_ranking: 0, + }; + let modelUuid = existing?.uuid || ""; + let created = false; + let updated = false; + + if (modelUuid) { + const update = await apiJson(backendUrl, `/api/v1/provider/models/llm/${encodeURIComponent(modelUuid)}`, { + method: "PUT", + token, + body, + }); + if (isApiFailure(update)) { + throw new Error(update.json.msg || "Failed to update fake provider model."); + } + updated = true; + } else { + const create = await apiJson(backendUrl, "/api/v1/provider/models/llm", { + method: "POST", + token, + body, + }); + modelUuid = create.json.data?.uuid || ""; + if (isApiFailure(create) || !modelUuid) { + throw new Error(create.json.msg || "Failed to create fake provider model."); + } + created = true; + } + + const test = await apiJson(backendUrl, `/api/v1/provider/models/llm/${encodeURIComponent(modelUuid)}/test`, { + method: "POST", + token, + body: { extra_args: {} }, + }); + if (isApiFailure(test)) { + throw new Error(safeReason(test.json.msg || test.json.message || "Fake provider model test failed.")); + } + + return { + uuid: modelUuid, + name, + created, + updated, + test_status: "pass", + test_reason: "", + }; +} + +async function ensurePipeline({ backendUrl, token, name, modelUuid }) { + const list = await apiJson(backendUrl, "/api/v1/pipelines", { token }); + if (isApiFailure(list)) { + throw new Error(list.json.msg || "Failed to list pipelines."); + } + const pipelines = list.json.data?.pipelines || []; + let pipeline = pipelines.find((item) => item.name === name) || null; + let created = false; + + if (!pipeline) { + const create = await apiJson(backendUrl, "/api/v1/pipelines", { + method: "POST", + token, + body: { + name, + description: QA_RESOURCE_DESCRIPTION, + emoji: "QA", + }, + }); + const pipelineId = create.json.data?.uuid || ""; + if (isApiFailure(create) || !pipelineId) { + throw new Error(create.json.msg || "Failed to create fake provider pipeline."); + } + created = true; + pipeline = { uuid: pipelineId }; + } + + const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { token }); + pipeline = loaded.json.data?.pipeline || null; + if (isApiFailure(loaded) || !pipeline?.uuid) { + throw new Error(loaded.json.msg || "Failed to load fake provider pipeline."); + } + + const config = pipeline.config && typeof pipeline.config === "object" ? pipeline.config : {}; + const ai = config.ai && typeof config.ai === "object" ? config.ai : {}; + const existingLocalAgentConfig = ai["local-agent"] && typeof ai["local-agent"] === "object" + ? ai["local-agent"] + : {}; + const localAgentConfig = { + timeout: 60, + prompt: [{ role: "system", content: "You are a deterministic QA assistant. Reply exactly as instructed." }], + "remove-think": false, + "knowledge-bases": [], + "box-session-id-template": "{launcher_type}_{launcher_id}", + "retrieval-top-k": 5, + "rerank-model": "", + "rerank-top-k": 5, + "max-tool-iterations": 20, + "tool-execution-mode": "parallel", + "max-tool-result-chars": 20000, + "context-history-fetch-limit": 20, + "context-window-tokens": 8192, + "context-reserve-tokens": 1024, + "context-keep-recent-tokens": 2048, + "context-summary-tokens": 1024, + ...existingLocalAgentConfig, + // Current backend truncation still reads this field directly. + "max-round": positiveInteger(existingLocalAgentConfig["max-round"], 10), + model: { + primary: modelUuid, + fallbacks: [], + }, + }; + const updatedConfig = { + ...config, + ai: { + ...ai, + runner: { + ...(ai.runner && typeof ai.runner === "object" ? ai.runner : {}), + id: RUNNER_ID, + runner: RUNNER_ID, + "expire-time": 0, + }, + "local-agent": localAgentConfig, + }, + }; + + const update = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { + method: "PUT", + token, + body: { + name, + description: QA_RESOURCE_DESCRIPTION, + emoji: "QA", + config: updatedConfig, + }, + }); + if (isApiFailure(update)) { + throw new Error(update.json.msg || "Failed to update fake provider pipeline."); + } + + return { + pipeline_id: pipeline.uuid, + pipeline_name: name, + created, + updated: true, + }; +} + +function isApiFailure(response) { + return response.status >= 400 || (response.json.code !== undefined && response.json.code !== 0); +} + +function positiveInteger(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function nonNegativeInteger(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; +} + +function httpFaultStatus(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 400 && parsed <= 599 ? parsed : fallback; +} + +function envBool(value, fallback) { + if (value === undefined || value === "") return fallback; + if (/^(1|true|yes|on)$/i.test(String(value))) return true; + if (/^(0|false|no|off)$/i.test(String(value))) return false; + return fallback; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function safeReason(value) { + return redact(String(value || "")).slice(0, 1000); +} + +async function upsertEnvLocal(path, updates) { + await mkdir(dirname(path), { recursive: true }); + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch { + text = ""; + } + const lines = text.split(/\r?\n/); + const seen = new Set(); + const next = lines.map((line) => { + const trimmed = line.trim(); + const equals = trimmed.indexOf("="); + if (equals <= 0 || trimmed.startsWith("#")) return line; + const key = trimmed.slice(0, equals).trim(); + if (!(key in updates)) return line; + seen.add(key); + return `${key}=${updates[key]}`; + }); + for (const [key, value] of Object.entries(updates)) { + if (!seen.has(key)) next.push(`${key}=${value}`); + } + await writeFile(path, `${next.filter((line, index) => line !== "" || index < next.length - 1).join("\n")}\n`, "utf8"); +} diff --git a/skills/scripts/e2e/ensure-langrag-sentinel-kb.mjs b/skills/scripts/e2e/ensure-langrag-sentinel-kb.mjs new file mode 100755 index 000000000..b2cf9e7b2 --- /dev/null +++ b/skills/scripts/e2e/ensure-langrag-sentinel-kb.mjs @@ -0,0 +1,293 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + apiJson, + ensureEvidence, + evidencePaths, + loadEnvFiles, + resetAndAuthLocalUser, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = env.LBS_CASE_ID || "ensure-langrag-sentinel-kb"; + +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const user = env.LANGBOT_E2E_LOGIN_USER || ""; +const password = env.LANGBOT_E2E_LOGIN_PASSWORD || "LangBotE2ELocalPass!2026"; +const expectedText = env.LANGBOT_E2E_EXPECTED_TEXT || "azalea-cobalt-7421"; +const query = env.LANGBOT_E2E_RETRIEVE_QUERY || "What is the local agent runner retrieval sentinel?"; +const writeEnv = process.argv.includes("--write-env"); +const checkOnly = process.argv.includes("--check-only"); +const envLocalPath = resolve("skills/.env.local"); +const kbName = env.LANGBOT_E2E_RAG_KB_NAME || "qa-local-agent-rag"; +const sentinelPath = resolve(env.LANGBOT_E2E_RAG_SENTINEL_DOC || "skills/langbot-testing/fixtures/rag/sentinel-doc.txt"); +const waitMs = Number(env.LANGBOT_E2E_RAG_WAIT_MS || 180_000); + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + backend_url: backendUrl, + expected_text: expectedText, + query, + kb_uuid: "", + kb_name: "", + kb_created: false, + uploaded_file_id: "", + store_task_id: "", + embedding_model_uuid: "", + engine_plugin_id: "", + checked_bases: [], + file_statuses: [], + wrote_env: false, + evidence: { + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["api_diagnostic"], +}; + +try { + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + if (!user) throw new Error("LANGBOT_E2E_LOGIN_USER is required."); + + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + const basesResponse = await apiJson(backendUrl, "/api/v1/knowledge/bases", { token: auth.token }); + if (basesResponse.status >= 400 || basesResponse.json.code !== 0) { + throw new Error(basesResponse.json.msg || `Failed to list knowledge bases: HTTP ${basesResponse.status}.`); + } + + let bases = basesResponse.json.data?.bases || []; + await findSentinelBase(backendUrl, auth.token, bases, result); + + if (!result.kb_uuid && !checkOnly) { + const targetBase = bases.find((base) => { + const uuid = base.uuid || base.id || ""; + return (base.name || "") === kbName && !hasRetrieveFailure(result.checked_bases, uuid); + }); + result.kb_uuid = targetBase?.uuid || targetBase?.id || ""; + result.kb_name = targetBase?.name || kbName; + + if (!result.kb_uuid) { + const setup = await createKnowledgeBase(backendUrl, auth.token, kbName); + result.kb_uuid = setup.kbUuid; + result.kb_name = kbName; + result.kb_created = true; + result.embedding_model_uuid = setup.embeddingModelUuid; + result.engine_plugin_id = setup.enginePluginId; + } + + const upload = await uploadDocument(backendUrl, auth.token, sentinelPath); + result.uploaded_file_id = upload.fileId; + + const store = await apiJson(backendUrl, `/api/v1/knowledge/bases/${encodeURIComponent(result.kb_uuid)}/files`, { + method: "POST", + token: auth.token, + body: { file_id: upload.fileId }, + }); + if (store.status >= 400 || store.json.code !== 0) { + throw new Error(store.json.msg || `Failed to store file in knowledge base: HTTP ${store.status}.`); + } + result.store_task_id = store.json.data?.task_id || ""; + + const ready = await waitForSentinel(backendUrl, auth.token, result.kb_uuid, query, expectedText, waitMs); + result.file_statuses = ready.fileStatuses; + if (ready.matched) { + result.checked_bases.push(ready.checked); + } + } + + if (!result.kb_uuid) { + result.status = "env_issue"; + result.reason = checkOnly + ? `No existing knowledge base retrieved expected sentinel: ${expectedText}` + : `Could not create or verify LangRAG sentinel knowledge base: ${expectedText}`; + } else { + if (writeEnv) { + await upsertEnvLocal(envLocalPath, { + LANGBOT_LOCAL_AGENT_RAG_KB_UUID: result.kb_uuid, + }); + result.wrote_env = true; + } + result.status = "pass"; + result.reason = `Found LangRAG sentinel knowledge base: ${result.kb_uuid}`; + } +} catch (error) { + result.status = /not configured|required|No existing knowledge base/.test(error.message) ? "env_issue" : "fail"; + result.reason = error.message; +} finally { + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); + +async function findSentinelBase(backendUrl, token, bases, result) { + for (const base of bases) { + const uuid = base.uuid || base.id || ""; + if (!uuid) continue; + const checked = await retrieveSentinel(backendUrl, token, uuid, base.name || "", result.query, result.expected_text); + result.checked_bases.push(checked); + if (checked.matched) { + result.kb_uuid = uuid; + result.kb_name = checked.name; + return; + } + } +} + +async function createKnowledgeBase(backendUrl, token, name) { + const enginesResponse = await apiJson(backendUrl, "/api/v1/knowledge/engines", { token }); + if (enginesResponse.status >= 400 || enginesResponse.json.code !== 0) { + throw new Error(enginesResponse.json.msg || `Failed to list knowledge engines: HTTP ${enginesResponse.status}.`); + } + const engines = enginesResponse.json.data?.engines || []; + const engine = engines.find((item) => item.plugin_id === "langbot-team/LangRAG") + || engines.find((item) => JSON.stringify(item.name || item.label || "").includes("LangRAG")); + const enginePluginId = engine?.plugin_id || ""; + if (!enginePluginId) throw new Error("LangRAG knowledge engine is not installed."); + + const embeddingModelUuid = await pickEmbeddingModel(backendUrl, token); + const create = await apiJson(backendUrl, "/api/v1/knowledge/bases", { + method: "POST", + token, + body: { + name, + description: "Automated LangBot agent-runner RAG sentinel knowledge base.", + knowledge_engine_plugin_id: enginePluginId, + creation_settings: { + embedding_model_uuid: embeddingModelUuid, + index_type: "chunk", + chunk_size: 512, + overlap: 50, + }, + retrieval_settings: { + top_k: 5, + search_type: "vector", + query_rewrite: "off", + rerank: "off", + context_window: 0, + }, + }, + }); + const kbUuid = create.json.data?.uuid || ""; + if (create.status >= 400 || create.json.code !== 0 || !kbUuid) { + throw new Error(create.json.msg || `Failed to create knowledge base: HTTP ${create.status}.`); + } + return { kbUuid, embeddingModelUuid, enginePluginId }; +} + +async function pickEmbeddingModel(backendUrl, token) { + const configured = env.LANGBOT_LOCAL_AGENT_RAG_EMBEDDING_MODEL_UUID || env.LANGBOT_RAG_EMBEDDING_MODEL_UUID || ""; + if (configured) return configured; + + const modelsResponse = await apiJson(backendUrl, "/api/v1/provider/models/embedding", { token }); + if (modelsResponse.status >= 400 || modelsResponse.json.code !== 0) { + throw new Error(modelsResponse.json.msg || `Failed to list embedding models: HTTP ${modelsResponse.status}.`); + } + const models = modelsResponse.json.data?.models || []; + const preferred = models.find((model) => /chroma|MiniLM/i.test(model.name || "")) + || models.find((model) => /text-embedding-3-small/i.test(model.name || "")) + || [...models].sort((a, b) => (a.prefered_ranking ?? 9999) - (b.prefered_ranking ?? 9999))[0]; + const uuid = preferred?.uuid || ""; + if (!uuid) throw new Error("No embedding model is configured."); + return uuid; +} + +async function uploadDocument(backendUrl, token, path) { + const bytes = await readFile(path); + const form = new FormData(); + form.append("file", new Blob([bytes], { type: "text/plain" }), "sentinel-doc.txt"); + const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/files/documents`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + body: form, + }); + const json = await response.json().catch(() => ({})); + const fileId = json.data?.file_id || ""; + if (response.status >= 400 || json.code !== 0 || !fileId) { + throw new Error(json.msg || `Failed to upload sentinel document: HTTP ${response.status}.`); + } + return { fileId }; +} + +async function waitForSentinel(backendUrl, token, kbUuid, query, expectedText, timeoutMs) { + const started = Date.now(); + let fileStatuses = []; + let lastChecked = null; + while (Date.now() - started < timeoutMs) { + const files = await apiJson(backendUrl, `/api/v1/knowledge/bases/${encodeURIComponent(kbUuid)}/files`, { token }); + fileStatuses = files.json.data?.files || fileStatuses; + lastChecked = await retrieveSentinel(backendUrl, token, kbUuid, kbName, query, expectedText); + if (lastChecked.matched) { + return { matched: true, fileStatuses, checked: lastChecked }; + } + if (fileStatuses.some((item) => item.status === "failed")) break; + await sleep(2_000); + } + result.reason = lastChecked?.msg + || `LangRAG sentinel was not retrievable within ${timeoutMs}ms; file statuses: ${JSON.stringify(fileStatuses)}`; + result.kb_uuid = ""; + return { matched: false, fileStatuses, checked: lastChecked }; +} + +async function retrieveSentinel(backendUrl, token, uuid, name, query, expectedText) { + const retrieve = await apiJson(backendUrl, `/api/v1/knowledge/bases/${encodeURIComponent(uuid)}/retrieve`, { + method: "POST", + token, + body: { query }, + }); + const text = JSON.stringify(retrieve.json.data?.results || []); + return { + uuid, + name, + http_status: retrieve.status, + code: retrieve.json.code ?? null, + msg: retrieve.json.msg || "", + matched: text.includes(expectedText), + }; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function hasRetrieveFailure(checkedBases, uuid) { + const checked = checkedBases.find((item) => item.uuid === uuid); + return checked && (checked.http_status >= 500 || (typeof checked.code === "number" && checked.code < 0)); +} + +async function upsertEnvLocal(path, values) { + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch { + text = ""; + } + const lines = text.split(/\r?\n/); + const keys = new Set(Object.keys(values)); + const output = []; + for (const line of lines) { + const match = line.match(/^([A-Z][A-Z0-9_]*)=/); + if (match && keys.has(match[1])) { + output.push(`${match[1]}=${values[match[1]]}`); + keys.delete(match[1]); + } else if (line !== "" || output.length > 0) { + output.push(line); + } + } + if (keys.size > 0 && output.length > 0 && output[output.length - 1] !== "") output.push(""); + for (const key of keys) output.push(`${key}=${values[key]}`); + await writeFile(path, `${output.join("\n").replace(/\n+$/, "")}\n`, "utf8"); +} diff --git a/skills/scripts/e2e/ensure-local-agent-pipeline.mjs b/skills/scripts/e2e/ensure-local-agent-pipeline.mjs new file mode 100755 index 000000000..da4336211 --- /dev/null +++ b/skills/scripts/e2e/ensure-local-agent-pipeline.mjs @@ -0,0 +1,609 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + apiJson, + bodyText, + createBrowser, + ensureEvidence, + evidencePaths, + loadEnvFiles, + redact, + resetAndAuthLocalUser, + safeScreenshot, + setBrowserToken, + verifyBrowserToken, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const RUNNER_ID = "local-agent"; +const SPACE_PROVIDER_UUID = "00000000-0000-0000-0000-000000000000"; +const DEFAULT_PIPELINE_NAME = "Agent QA Local Agent Debug Chat"; +const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; +const DEFAULT_MODEL_TEST_LIMIT = 8; +const DEFAULT_MODEL_FALLBACK_COUNT = 3; +const caseId = "ensure-local-agent-pipeline"; + +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const writeEnv = process.argv.includes("--write-env"); +const pipelineName = env.LANGBOT_E2E_CREATE_PIPELINE_NAME || env.LANGBOT_LOCAL_AGENT_PIPELINE_NAME || DEFAULT_PIPELINE_NAME; +const frontendUrl = env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const envLocalPath = resolve("skills/.env.local"); + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + frontend_url: frontendUrl, + backend_url: backendUrl, + pipeline_name: pipelineName, + pipeline_id: "", + pipeline_url: "", + runner_id: RUNNER_ID, + selected_model_id: "", + selected_model_name: "", + fallback_model_ids: [], + model_count: 0, + space_model_count: 0, + scanned_space_model_count: 0, + tested_model_count: 0, + model_tests: [], + created: false, + updated: false, + wrote_env: false, + auth: null, + wizard: null, + browser_token_check: null, + page_signal: "", + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["api_diagnostic", "console", "network", "screenshot"], +}; + +let browser; + +try { + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + + const user = env.LANGBOT_E2E_LOGIN_USER || ""; + const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD; + if (!user) { + result.status = "env_issue"; + throw new Error("LANGBOT_E2E_LOGIN_USER is required so this setup can create/update the pipeline via backend API."); + } + + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + result.auth = { + source: "local_recovery_login", + user, + backend_token_check: auth.check, + }; + + const wizard = await skipWizard({ backendUrl, token: auth.token }); + result.wizard = wizard; + if (wizard.status !== "pass") { + result.status = "fail"; + throw new Error(wizard.reason || "Failed to mark the local QA wizard as skipped."); + } + + const prepared = await ensureLocalAgentPipeline({ + backendUrl, + token: auth.token, + pipelineName, + runnerId: RUNNER_ID, + }); + Object.assign(result, prepared); + if (result.pipeline_id) { + result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(result.pipeline_id)}`; + } + + if (writeEnv && result.pipeline_id) { + await upsertEnvLocal(envLocalPath, { + LANGBOT_E2E_LOGIN_USER: user, + LANGBOT_PIPELINE_URL: result.pipeline_url, + LANGBOT_PIPELINE_NAME: result.pipeline_name || pipelineName, + LANGBOT_LOCAL_AGENT_PIPELINE_URL: result.pipeline_url, + LANGBOT_LOCAL_AGENT_PIPELINE_NAME: result.pipeline_name || pipelineName, + ...(result.selected_model_id ? { + LANGBOT_LOCAL_AGENT_MODEL_UUID: result.selected_model_id, + LANGBOT_E2E_MODEL_UUID: result.selected_model_id, + } : {}), + }); + result.wrote_env = true; + } + + browser = await createBrowser(paths); + const { page } = browser; + await setBrowserToken(page, frontendUrl, auth.token); + const browserCheck = await verifyBrowserToken(page, backendUrl); + result.browser_token_check = browserCheck; + if (!browserCheck.authenticated) { + throw new Error(browserCheck.reason || "Browser token check failed after setup."); + } + await page.goto(result.pipeline_url || frontendUrl, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + const text = await bodyText(page); + result.page_signal = ["Pipelines", "流水线", pipelineName].find((signal) => text.includes(signal)) || ""; +} catch (error) { + result.status = result.status === "env_issue" ? "env_issue" : "fail"; + result.reason = result.reason || error.message; +} finally { + if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); + if (browser) await browser.close().catch(() => {}); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); + +async function skipWizard({ backendUrl, token }) { + const response = await apiJson(backendUrl, "/api/v1/system/wizard/completed", { + method: "POST", + token, + body: { status: "skipped" }, + }); + const ok = response.status < 400 && response.json.code === 0; + return { + status: ok ? "pass" : "fail", + http_status: response.status, + code: response.json.code ?? null, + reason: ok ? "Wizard marked skipped for local QA." : response.json.msg || "Wizard status update failed.", + }; +} + +async function ensureLocalAgentPipeline({ backendUrl, token, pipelineName, runnerId }) { + const [pipelineList, modelList] = await Promise.all([ + apiJson(backendUrl, "/api/v1/pipelines", { token }), + apiJson(backendUrl, "/api/v1/provider/models/llm", { token }), + ]); + + if (isApiFailure(pipelineList)) { + return { + status: "fail", + reason: pipelineList.json.msg || "Failed to list pipelines.", + list_status: pipelineList.status, + }; + } + if (isApiFailure(modelList)) { + return { + status: "fail", + reason: modelList.json.msg || "Failed to list LLM models.", + model_status: modelList.status, + }; + } + + const models = modelList.json.data?.models || []; + const skippedModelIds = new Set( + String(env.LANGBOT_E2E_SKIP_MODEL_UUIDS || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ); + const skippedModelNames = new Set( + String(env.LANGBOT_E2E_SKIP_MODEL_NAMES || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ); + const spaceModels = models.filter((model) => isSpaceModel(model) && !skippedModelIds.has(model.uuid)); + const pipelines = pipelineList.json.data?.pipelines || []; + let pipeline = pipelines.find((item) => item.name === pipelineName) || null; + let created = false; + + if (!pipeline) { + const createdResponse = await apiJson(backendUrl, "/api/v1/pipelines", { + method: "POST", + token, + body: { + name: pipelineName, + description: "Local QA pipeline for AgentRunner Debug Chat smoke tests.", + emoji: "QA", + }, + }); + if (isApiFailure(createdResponse)) { + return { + status: "fail", + reason: createdResponse.json.msg || "Failed to create pipeline.", + create_status: createdResponse.status, + model_count: models.length, + space_model_count: spaceModels.length, + }; + } + const pipelineId = createdResponse.json.data?.uuid || ""; + const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipelineId)}`, { token }); + pipeline = loaded.json.data?.pipeline || null; + created = true; + } + + if (!pipeline?.uuid) { + return { + status: "fail", + reason: "Pipeline was not created or resolved.", + model_count: models.length, + space_model_count: spaceModels.length, + }; + } + + const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { token }); + if (isApiFailure(loaded) || !loaded.json.data?.pipeline) { + return { + status: "fail", + reason: loaded.json.msg || "Failed to load pipeline.", + get_status: loaded.status, + pipeline_id: pipeline.uuid, + model_count: models.length, + space_model_count: spaceModels.length, + }; + } + pipeline = loaded.json.data.pipeline; + + const config = pipeline.config && typeof pipeline.config === "object" ? pipeline.config : {}; + const ai = config.ai && typeof config.ai === "object" ? config.ai : {}; + const rawExistingLocalAgentConfig = ai["local-agent"] && typeof ai["local-agent"] === "object" + ? ai["local-agent"] + : {}; + const existingLocalAgentConfig = rawExistingLocalAgentConfig; + const existingModel = existingLocalAgentConfig.model && typeof existingLocalAgentConfig.model === "object" + ? existingLocalAgentConfig.model + : {}; + const requestedModelId = env.LANGBOT_LOCAL_AGENT_MODEL_UUID || env.LANGBOT_E2E_MODEL_UUID || ""; + const selected = await selectWorkingSpaceModel({ + backendUrl, + token, + models, + skippedModelIds, + skippedModelNames, + requestedModelId, + existingModelId: existingModel.primary || "", + }); + const selectedModelId = selected.selected_model_id || ""; + const localAgentConfig = { + timeout: 300, + prompt: [{ role: "system", content: "You are a helpful assistant." }], + "remove-think": false, + "knowledge-bases": [], + "box-session-id-template": "{launcher_type}_{launcher_id}", + "retrieval-top-k": 5, + "rerank-model": "", + "rerank-top-k": 5, + "max-tool-iterations": 20, + "tool-execution-mode": "parallel", + "max-tool-result-chars": 20000, + "context-history-fetch-limit": 50, + "context-window-tokens": 200000, + "context-reserve-tokens": 16384, + "context-keep-recent-tokens": 20000, + "context-summary-tokens": 8000, + ...existingLocalAgentConfig, + // Current backend truncation still reads this field directly. + "max-round": positiveInteger(existingLocalAgentConfig["max-round"], 10), + model: { + primary: selectedModelId, + fallbacks: selected.fallback_model_ids || [], + }, + }; + const updatedConfig = { + ...config, + ai: { + ...ai, + runner: { + ...(ai.runner && typeof ai.runner === "object" ? ai.runner : {}), + id: runnerId, + runner: runnerId, + "expire-time": 0, + }, + "local-agent": localAgentConfig, + }, + }; + + const updateResponse = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { + method: "PUT", + token, + body: { + name: pipelineName, + description: "Local QA pipeline for AgentRunner Debug Chat smoke tests.", + emoji: "QA", + config: updatedConfig, + }, + }); + if (isApiFailure(updateResponse)) { + return { + status: "fail", + reason: updateResponse.json.msg || "Failed to update pipeline config.", + update_status: updateResponse.status, + pipeline_id: pipeline.uuid, + model_count: models.length, + space_model_count: spaceModels.length, + scanned_space_model_count: selected.scanned_space_model_count, + tested_model_count: selected.tested_model_count, + model_tests: selected.model_tests, + selected_model_id: selectedModelId, + selected_model_name: selected.selected_model_name, + fallback_model_ids: selected.fallback_model_ids, + }; + } + + return { + status: selectedModelId ? "pass" : "env_issue", + reason: selectedModelId + ? `Local-agent pipeline is configured for Debug Chat with Space model ${selected.selected_model_name || selectedModelId} and ${selected.fallback_model_ids.length} fallback(s).` + : selected.reason || "No working Space LLM model is configured in this LangBot instance.", + pipeline_id: pipeline.uuid, + pipeline_name: pipelineName, + model_count: models.length, + space_model_count: spaceModels.length, + scanned_space_model_count: selected.scanned_space_model_count, + tested_model_count: selected.tested_model_count, + model_tests: selected.model_tests, + selected_model_id: selectedModelId, + selected_model_name: selected.selected_model_name, + fallback_model_ids: selected.fallback_model_ids, + created, + updated: true, + }; +} + +function isApiFailure(response) { + return response.status >= 400 || (response.json.code !== undefined && response.json.code !== 0); +} + +function isSpaceModel(model) { + const provider = model?.provider && typeof model.provider === "object" ? model.provider : {}; + return model?.provider_uuid === SPACE_PROVIDER_UUID + || provider.uuid === SPACE_PROVIDER_UUID + || provider.requester === "space-chat-completions" + || provider.name === "LangBot Models"; +} + +async function selectWorkingSpaceModel({ + backendUrl, + token, + models, + skippedModelIds, + skippedModelNames, + requestedModelId, + existingModelId, +}) { + const modelTests = []; + const testLimit = positiveInteger(env.LANGBOT_E2E_MODEL_TEST_LIMIT, DEFAULT_MODEL_TEST_LIMIT); + const fallbackCount = positiveInteger(env.LANGBOT_E2E_MODEL_FALLBACK_COUNT, DEFAULT_MODEL_FALLBACK_COUNT); + const workingModels = []; + const spaceModels = rankModels(models.filter((model) => ( + model.uuid + && isSpaceModel(model) + && !skippedModelIds.has(model.uuid) + && !skippedModelNames.has(model.name) + ))); + const requestedModel = requestedModelId + ? spaceModels.find((model) => model.uuid === requestedModelId) || null + : null; + const existingModel = existingModelId + ? spaceModels.find((model) => model.uuid === existingModelId) || null + : null; + const candidates = uniqueCandidates([ + ...(requestedModel ? [existingCandidate(requestedModel, "requested")] : []), + ...(existingModel ? [existingCandidate(existingModel, "existing-pipeline")] : []), + ...spaceModels.map((model) => existingCandidate(model, "configured-space")), + ]); + + let scanResult = { status: "skipped", models: [], reason: "" }; + if (env.LANGBOT_E2E_SCAN_SPACE_MODELS !== "false") { + scanResult = await scanSpaceModels({ backendUrl, token }); + if (scanResult.status === "pass") { + const knownNames = new Set(spaceModels.map((model) => model.name)); + candidates.push(...scanResult.models + .filter((model) => model.name && !knownNames.has(model.name) && !skippedModelNames.has(model.name)) + .map((model) => scannedCandidate(model))); + } + } + + const unique = uniqueCandidates(candidates); + for (const candidate of unique.slice(0, testLimit)) { + const test = await ensureAndTestModel({ backendUrl, token, candidate }); + modelTests.push(test); + if (test.status === "pass" && test.model_uuid) { + workingModels.push(test); + if (workingModels.length >= fallbackCount + 1) break; + } + } + + if (workingModels.length > 0) { + const [primary, ...fallbacks] = workingModels; + return { + status: "pass", + reason: "", + selected_model_id: primary.model_uuid, + selected_model_name: primary.model_name, + fallback_model_ids: fallbacks.map((model) => model.model_uuid), + scanned_space_model_count: scanResult.models.length, + tested_model_count: modelTests.length, + model_tests: modelTests, + }; + } + + const baseReason = unique.length === 0 + ? scanResult.reason || "No Space LLM model candidates are available." + : `No working Space LLM model found after testing ${modelTests.length} candidate(s).`; + return { + status: "env_issue", + reason: requestedModelId && !requestedModel + ? `Requested Space LLM model ${requestedModelId} is missing or skipped; ${baseReason}` + : baseReason, + selected_model_id: "", + selected_model_name: "", + fallback_model_ids: [], + scanned_space_model_count: scanResult.models.length, + tested_model_count: modelTests.length, + model_tests: modelTests, + }; +} + +async function scanSpaceModels({ backendUrl, token }) { + const response = await apiJson( + backendUrl, + `/api/v1/provider/providers/${encodeURIComponent(SPACE_PROVIDER_UUID)}/scan-models?type=llm`, + { token }, + ); + if (isApiFailure(response)) { + return { + status: "env_issue", + models: [], + reason: safeReason(response.json.msg || response.json.message || "Failed to scan Space LLM models."), + }; + } + return { + status: "pass", + models: response.json.data?.models || [], + reason: "", + }; +} + +async function ensureAndTestModel({ backendUrl, token, candidate }) { + let modelUuid = candidate.uuid || ""; + let created = false; + if (!modelUuid) { + const create = await apiJson(backendUrl, "/api/v1/provider/models/llm", { + method: "POST", + token, + body: { + name: candidate.name, + provider_uuid: SPACE_PROVIDER_UUID, + abilities: candidate.abilities || [], + context_length: candidate.context_length ?? null, + extra_args: {}, + prefered_ranking: positiveInteger(candidate.prefered_ranking, 0), + }, + }); + modelUuid = create.json.data?.uuid || ""; + if (isApiFailure(create) || !modelUuid) { + return modelTestResult(candidate, { + status: "fail", + reason: safeReason(create.json.msg || "Failed to create scanned Space model."), + http_status: create.status, + }); + } + created = true; + } + + const test = await apiJson(backendUrl, `/api/v1/provider/models/llm/${encodeURIComponent(modelUuid)}/test`, { + method: "POST", + token, + body: { extra_args: {} }, + }); + const passed = !isApiFailure(test); + if (!passed && created) { + await apiJson(backendUrl, `/api/v1/provider/models/llm/${encodeURIComponent(modelUuid)}`, { + method: "DELETE", + token, + }).catch(() => {}); + } + return modelTestResult(candidate, { + status: passed ? "pass" : "fail", + reason: passed ? "" : safeReason(test.json.msg || test.json.message || "Space model test failed."), + http_status: test.status, + model_uuid: modelUuid, + created, + }); +} + +function modelTestResult(candidate, details) { + return { + source: candidate.source, + model_uuid: details.model_uuid || candidate.uuid || "", + model_name: candidate.name, + status: details.status, + reason: details.reason || "", + http_status: details.http_status ?? null, + created: Boolean(details.created), + }; +} + +function existingCandidate(model, source) { + return { + source, + uuid: model.uuid, + name: model.name, + abilities: model.abilities || [], + context_length: model.context_length, + prefered_ranking: model.prefered_ranking, + }; +} + +function scannedCandidate(model) { + return { + source: "scanned-space", + uuid: "", + name: model.name || model.id, + abilities: model.abilities || [], + context_length: model.context_length, + prefered_ranking: model.prefered_ranking, + }; +} + +function uniqueCandidates(candidates) { + const seen = new Set(); + const result = []; + for (const candidate of candidates) { + const key = candidate.uuid ? `uuid:${candidate.uuid}` : `name:${candidate.name}`; + if (!candidate.name || seen.has(key)) continue; + seen.add(key); + result.push(candidate); + } + return result; +} + +function rankModels(models) { + return [...models].sort((left, right) => { + const leftRank = Number.isFinite(Number(left.prefered_ranking)) ? Number(left.prefered_ranking) : 9999; + const rightRank = Number.isFinite(Number(right.prefered_ranking)) ? Number(right.prefered_ranking) : 9999; + if (leftRank !== rightRank) return leftRank - rightRank; + return String(left.name || "").localeCompare(String(right.name || "")); + }); +} + +function positiveInteger(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function safeReason(value) { + return redact(String(value || "")).slice(0, 1000); +} + +async function upsertEnvLocal(path, updates) { + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch { + text = ""; + } + const lines = text.split(/\r?\n/); + const seen = new Set(); + const next = lines.map((line) => { + const trimmed = line.trim(); + const equals = trimmed.indexOf("="); + if (equals <= 0 || trimmed.startsWith("#")) return line; + const key = trimmed.slice(0, equals).trim(); + if (!(key in updates)) return line; + seen.add(key); + return `${key}=${updates[key]}`; + }); + for (const [key, value] of Object.entries(updates)) { + if (!seen.has(key)) next.push(`${key}=${value}`); + } + await writeFile(path, `${next.filter((line, index) => line !== "" || index < next.length - 1).join("\n")}\n`, "utf8"); +} diff --git a/skills/scripts/e2e/ensure-qa-agent-runner-pipeline.mjs b/skills/scripts/e2e/ensure-qa-agent-runner-pipeline.mjs new file mode 100755 index 000000000..abc43241a --- /dev/null +++ b/skills/scripts/e2e/ensure-qa-agent-runner-pipeline.mjs @@ -0,0 +1,230 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + apiJson, + ensureEvidence, + evidencePaths, + loadEnvFiles, + resetAndAuthLocalUser, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const RUNNER_ID = "plugin:qa/agent-runner/default"; +const DEFAULT_PIPELINE_NAME = "Agent QA Deterministic Runner Debug Chat"; +const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; +const caseId = "ensure-qa-agent-runner-pipeline"; + +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const writeEnv = process.argv.includes("--write-env"); +const frontendUrl = env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const pipelineName = env.LANGBOT_E2E_CREATE_PIPELINE_NAME || env.LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME || DEFAULT_PIPELINE_NAME; +const envLocalPath = resolve("skills/.env.local"); + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + frontend_url: frontendUrl, + backend_url: backendUrl, + pipeline_name: pipelineName, + pipeline_id: "", + pipeline_url: "", + runner_id: RUNNER_ID, + wrote_env: false, + auth: null, + evidence: { + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["api_diagnostic"], +}; + +try { + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + + const user = env.LANGBOT_E2E_LOGIN_USER || ""; + const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD; + if (!user) { + throw new Error("LANGBOT_E2E_LOGIN_USER is required so this setup can create/update the pipeline via backend API."); + } + + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + result.auth = { + source: "local_recovery_login", + user, + backend_token_check: auth.check, + }; + + const prepared = await ensurePipeline({ + backendUrl, + token: auth.token, + pipelineName, + runnerId: RUNNER_ID, + runnerConfig: {}, + }); + Object.assign(result, prepared); + if (result.pipeline_id) { + result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(result.pipeline_id)}`; + } + + if (writeEnv && result.pipeline_id) { + await upsertEnvLocal(envLocalPath, { + LANGBOT_E2E_LOGIN_USER: user, + LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL: result.pipeline_url, + LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME: result.pipeline_name || pipelineName, + }); + result.wrote_env = true; + } +} catch (error) { + result.reason = result.reason || error.message; +} finally { + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); + +async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runnerConfig }) { + const pipelineList = await apiJson(backendUrl, "/api/v1/pipelines", { token }); + if (isApiFailure(pipelineList)) { + return { + status: "fail", + reason: pipelineList.json.msg || "Failed to list pipelines.", + list_status: pipelineList.status, + }; + } + + const pipelines = pipelineList.json.data?.pipelines || []; + let pipeline = pipelines.find((item) => item.name === pipelineName) || null; + let created = false; + + if (!pipeline) { + const createdResponse = await apiJson(backendUrl, "/api/v1/pipelines", { + method: "POST", + token, + body: { + name: pipelineName, + description: "Local QA pipeline for deterministic QA AgentRunner Debug Chat smoke tests.", + emoji: "QA", + }, + }); + if (isApiFailure(createdResponse)) { + return { + status: "fail", + reason: createdResponse.json.msg || "Failed to create pipeline.", + create_status: createdResponse.status, + }; + } + const pipelineId = createdResponse.json.data?.uuid || ""; + const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipelineId)}`, { token }); + pipeline = loaded.json.data?.pipeline || null; + created = true; + } + + if (!pipeline?.uuid) { + return { + status: "fail", + reason: "Pipeline was not created or resolved.", + }; + } + + const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { token }); + if (isApiFailure(loaded) || !loaded.json.data?.pipeline) { + return { + status: "fail", + reason: loaded.json.msg || "Failed to load pipeline.", + get_status: loaded.status, + pipeline_id: pipeline.uuid, + }; + } + pipeline = loaded.json.data.pipeline; + + const config = pipeline.config && typeof pipeline.config === "object" ? pipeline.config : {}; + const ai = config.ai && typeof config.ai === "object" ? config.ai : {}; + const runnerConfigs = ai.runner_config && typeof ai.runner_config === "object" ? ai.runner_config : {}; + const updatedConfig = { + ...config, + ai: { + ...ai, + runner: { + ...(ai.runner && typeof ai.runner === "object" ? ai.runner : {}), + id: runnerId, + "expire-time": 0, + }, + runner_config: { + ...runnerConfigs, + [runnerId]: runnerConfig, + }, + }, + }; + + const updateResponse = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { + method: "PUT", + token, + body: { + name: pipelineName, + description: "Local QA pipeline for deterministic QA AgentRunner Debug Chat smoke tests.", + emoji: "QA", + config: updatedConfig, + }, + }); + if (isApiFailure(updateResponse)) { + return { + status: "fail", + reason: updateResponse.json.msg || "Failed to update pipeline.", + update_status: updateResponse.status, + pipeline_id: pipeline.uuid, + }; + } + + return { + status: "pass", + reason: created ? "QA AgentRunner pipeline created and configured." : "QA AgentRunner pipeline updated.", + pipeline_id: pipeline.uuid, + pipeline_name: pipelineName, + created, + updated: true, + }; +} + +function isApiFailure(response) { + return response.status >= 400 || (response.json && response.json.code !== undefined && response.json.code !== 0); +} + +async function upsertEnvLocal(path, values) { + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch { + text = ""; + } + const lines = text.split(/\r?\n/); + const keys = new Set(Object.keys(values)); + const output = []; + for (const line of lines) { + const match = line.match(/^([A-Z][A-Z0-9_]*)=/); + if (match && keys.has(match[1])) { + output.push(`${match[1]}=${values[match[1]]}`); + keys.delete(match[1]); + } else if (line !== "" || output.length > 0) { + output.push(line); + } + } + if (keys.size > 0 && output.length > 0 && output[output.length - 1] !== "") { + output.push(""); + } + for (const key of keys) { + output.push(`${key}=${values[key]}`); + } + await writeFile(path, `${output.join("\n").replace(/\n+$/, "")}\n`, "utf8"); +} diff --git a/skills/scripts/e2e/fake-openai-provider.mjs b/skills/scripts/e2e/fake-openai-provider.mjs new file mode 100755 index 000000000..1cca9c46b --- /dev/null +++ b/skills/scripts/e2e/fake-openai-provider.mjs @@ -0,0 +1,496 @@ +#!/usr/bin/env node + +import { createServer } from "node:http"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { env, exit } from "node:process"; + +const args = parseArgs(process.argv.slice(2)); +const host = args.host || env.LANGBOT_FAKE_PROVIDER_HOST || "127.0.0.1"; +const port = integer(args.port ?? env.LANGBOT_FAKE_PROVIDER_PORT, 0); +const stateFile = args["state-file"] || env.LANGBOT_FAKE_PROVIDER_STATE_FILE || ""; +const modelName = env.LANGBOT_FAKE_PROVIDER_MODEL_NAME || "gpt-4o-mini"; +const config = { + response_text: env.LANGBOT_FAKE_PROVIDER_RESPONSE_TEXT || "OK", + first_token_delay_ms: integer(env.LANGBOT_FAKE_PROVIDER_FIRST_TOKEN_DELAY_MS, 25), + chunk_delay_ms: integer(env.LANGBOT_FAKE_PROVIDER_CHUNK_DELAY_MS, 10), + chunk_count: integer(env.LANGBOT_FAKE_PROVIDER_CHUNK_COUNT, 0), + fault_status: integer(env.LANGBOT_FAKE_PROVIDER_FAULT_STATUS, 500), + fail_first_n: integer(env.LANGBOT_FAKE_PROVIDER_FAIL_FIRST_N, 0), + fail_every_n: integer(env.LANGBOT_FAKE_PROVIDER_FAIL_EVERY_N, 0), + fail_after_first_chunk: bool(env.LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK, false), + dynamic_response: !/^(0|false|no|off)$/i.test(env.LANGBOT_FAKE_PROVIDER_DYNAMIC_RESPONSE || ""), + request_log_limit: integer(env.LANGBOT_FAKE_PROVIDER_REQUEST_LOG_LIMIT, 500), +}; + +let requestCount = 0; +const recentRequests = []; + +const server = createServer(async (request, response) => { + const startedAt = Date.now(); + const startedPerf = performance.now(); + let requestRecord = null; + const url = new URL(request.url || "/", `http://${request.headers.host || `${host}:${port}`}`); + try { + if (request.method === "GET" && url.pathname === "/healthz") { + sendJson(response, 200, { + ok: true, + model: modelName, + config, + request_count: requestCount, + recent_request_count: recentRequests.length, + }); + return; + } + + if (request.method === "GET" && url.pathname === "/__qa/config") { + sendJson(response, 200, { + ok: true, + model: modelName, + config, + request_count: requestCount, + recent_requests: recentRequests, + }); + return; + } + + if (request.method === "POST" && url.pathname === "/__qa/config") { + const body = await readJson(request); + applyConfig(body.config && typeof body.config === "object" ? body.config : body); + if (body.reset_request_count !== false) resetRequestState(); + sendJson(response, 200, { + ok: true, + model: modelName, + config, + request_count: requestCount, + }); + return; + } + + if (request.method === "POST" && url.pathname === "/__qa/reset") { + resetRequestState(); + sendJson(response, 200, { + ok: true, + model: modelName, + config, + request_count: requestCount, + }); + return; + } + + if (request.method === "GET" && ["/models", "/v1/models"].includes(url.pathname)) { + sendJson(response, 200, { + object: "list", + data: [ + { + id: modelName, + object: "model", + created: 1, + owned_by: "langbot-qa", + type: "llm", + }, + ], + }); + return; + } + + if (request.method === "POST" && ["/chat/completions", "/v1/chat/completions"].includes(url.pathname)) { + requestCount += 1; + const body = await readJson(request); + const requestId = `chatcmpl-langbot-fake-${requestCount}`; + const shouldFail = requestCount <= config.fail_first_n + || (config.fail_every_n > 0 && requestCount % config.fail_every_n === 0); + const replyText = responseTextForBody(body); + requestRecord = recordRequest({ + id: requestId, + request_number: requestCount, + path: url.pathname, + stream: Boolean(body.stream), + model: body.model || "", + message_count: Array.isArray(body.messages) ? body.messages.length : 0, + should_fail: shouldFail, + status: "running", + http_status: null, + expected_text: replyText, + response_text_preview: previewText(replyText), + started_at: new Date(startedAt).toISOString(), + started_epoch_ms: startedAt, + configured_first_token_delay_ms: config.first_token_delay_ms, + configured_chunk_delay_ms: config.chunk_delay_ms, + configured_chunk_count: config.chunk_count, + }); + + if (shouldFail) { + await sleep(config.first_token_delay_ms); + sendJson(response, config.fault_status, { + error: { + message: `LangBot fake provider injected HTTP ${config.fault_status}`, + type: "fake_provider_fault", + code: "fake_provider_fault", + }, + }); + finishRequestRecord(requestRecord, startedPerf, { + status: "http_fault", + http_status: config.fault_status, + }); + return; + } + + if (body.stream) { + await streamCompletion(response, { + requestId, + model: body.model || modelName, + content: replyText, + failAfterFirstChunk: config.fail_after_first_chunk, + requestRecord, + startedPerf, + }); + } else { + await sleep(config.first_token_delay_ms + config.chunk_delay_ms); + sendJson(response, 200, completionPayload({ + requestId, + model: body.model || modelName, + content: replyText, + })); + markRequestTiming(requestRecord, "first_chunk", startedPerf); + markRequestTiming(requestRecord, "first_content_chunk", startedPerf); + requestRecord.content_chunk_count = 1; + finishRequestRecord(requestRecord, startedPerf, { + status: "ok", + http_status: 200, + }); + } + return; + } + + sendJson(response, 404, { + error: { + message: `No fake provider route for ${request.method} ${url.pathname}`, + type: "not_found", + }, + }); + } catch (error) { + if (requestRecord) { + finishRequestRecord(requestRecord, startedPerf, { + status: "fake_provider_error", + http_status: 500, + error: error instanceof Error ? error.message : String(error), + }); + } + sendJson(response, 500, { + error: { + message: error instanceof Error ? error.message : String(error), + type: "fake_provider_error", + }, + }); + } finally { + const durationMs = Date.now() - startedAt; + if (url.pathname !== "/healthz") { + console.log(JSON.stringify({ + at: new Date().toISOString(), + method: request.method, + path: url.pathname, + duration_ms: durationMs, + })); + } + } +}); + +server.listen(port, host, async () => { + const address = server.address(); + const selectedPort = typeof address === "object" && address ? address.port : port; + const url = `http://${host}:${selectedPort}`; + const state = { + status: "ready", + pid: process.pid, + url, + base_url: `${url}/v1`, + model: modelName, + started_at: new Date().toISOString(), + }; + if (stateFile) { + const path = resolve(stateFile); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + } + console.log(JSON.stringify(state)); +}); + +server.on("error", (error) => { + console.error(JSON.stringify({ + status: "error", + reason: error instanceof Error ? error.message : String(error), + })); + exit(1); +}); + +process.on("SIGTERM", () => { + server.close(() => exit(0)); +}); + +function parseArgs(argv) { + const result = {}; + for (const item of argv) { + const match = item.match(/^--([^=]+)(?:=(.*))?$/); + if (!match) continue; + result[match[1]] = match[2] ?? "1"; + } + return result; +} + +function integer(value, fallback) { + const parsed = Number.parseInt(String(value ?? ""), 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +function bool(value, fallback) { + if (value === undefined || value === "") return fallback; + if (/^(1|true|yes|on)$/i.test(String(value))) return true; + if (/^(0|false|no|off)$/i.test(String(value))) return false; + return fallback; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))); +} + +async function readJson(request) { + let text = ""; + for await (const chunk of request) text += chunk.toString(); + if (!text) return {}; + return JSON.parse(text); +} + +function sendJson(response, status, payload) { + const text = `${JSON.stringify(payload)}\n`; + response.writeHead(status, { + "content-type": "application/json", + "content-length": Buffer.byteLength(text), + }); + response.end(text); +} + +function completionPayload({ requestId, model, content }) { + const completionTokens = tokenEstimate(content); + return { + id: requestId, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content, + }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: 8, + completion_tokens: completionTokens, + total_tokens: 8 + completionTokens, + }, + }; +} + +async function streamCompletion(response, { + requestId, + model, + content, + failAfterFirstChunk: failMidStream, + requestRecord, + startedPerf, +}) { + response.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + "connection": "keep-alive", + }); + + await sleep(config.first_token_delay_ms); + markRequestTiming(requestRecord, "first_chunk", startedPerf); + writeSse(response, { + id: requestId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }); + + const chunks = splitContent(content); + for (let index = 0; index < chunks.length; index += 1) { + await sleep(config.chunk_delay_ms); + if (index === 0) markRequestTiming(requestRecord, "first_content_chunk", startedPerf); + requestRecord.content_chunk_count = (requestRecord.content_chunk_count || 0) + 1; + writeSse(response, { + id: requestId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: { content: chunks[index] }, finish_reason: null }], + }); + if (failMidStream && index === 0) { + finishRequestRecord(requestRecord, startedPerf, { + status: "mid_stream_disconnect", + http_status: 200, + }); + response.destroy(new Error("LangBot fake provider injected mid-stream disconnect")); + return; + } + } + + await sleep(config.chunk_delay_ms); + const completionTokens = tokenEstimate(content); + writeSse(response, { + id: requestId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: 8, + completion_tokens: completionTokens, + total_tokens: 8 + completionTokens, + }, + }); + response.write("data: [DONE]\n\n"); + response.end(); + finishRequestRecord(requestRecord, startedPerf, { + status: "ok", + http_status: 200, + }); +} + +function writeSse(response, payload) { + response.write(`data: ${JSON.stringify(payload)}\n\n`); +} + +function splitContent(content) { + const text = String(content); + const requested = config.chunk_count; + if (requested <= 1 || text.length <= 1) return [text]; + const chunkSize = Math.max(1, Math.ceil(text.length / requested)); + const chunks = []; + for (let index = 0; index < text.length; index += chunkSize) { + chunks.push(text.slice(index, index + chunkSize)); + } + return chunks; +} + +function tokenEstimate(content) { + return Math.max(1, Math.ceil(String(content || "").length / 4)); +} + +function responseTextForBody(body) { + if (!config.dynamic_response) { + return config.response_text; + } + const messages = Array.isArray(body.messages) ? body.messages : []; + const lastUser = [...messages].reverse().find((message) => message?.role === "user"); + const text = flattenContent(lastUser?.content || ""); + const quoted = text.match(/["'“”](.{1,80}?)["'“”]/); + if (quoted?.[1]) return quoted[1].trim(); + const exact = text.match(/(?:reply|回复|输出|return)\s+(?:exactly\s+)?([A-Za-z0-9_.:@-]{1,80})/i); + if (exact?.[1]) return exact[1].trim().replace(/[。.!?]+$/, ""); + const only = text.match(/只回复\s*([A-Za-z0-9_.:@-]{1,80})/); + if (only?.[1]) return only[1].trim().replace(/[。.!?]+$/, ""); + return config.response_text; +} + +function flattenContent(content) { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((item) => { + if (typeof item === "string") return item; + if (item && typeof item === "object") return item.text || ""; + return ""; + }) + .join("\n"); + } + return ""; +} + +function recordRequest(entry) { + const item = { + ...entry, + at: new Date().toISOString(), + finished_at: null, + finished_epoch_ms: null, + duration_ms: null, + first_chunk_at: null, + first_chunk_epoch_ms: null, + first_chunk_ms: null, + first_content_chunk_at: null, + first_content_chunk_epoch_ms: null, + first_content_chunk_ms: null, + content_chunk_count: 0, + }; + recentRequests.push(item); + while (recentRequests.length > config.request_log_limit) recentRequests.shift(); + return item; +} + +function markRequestTiming(entry, key, startedPerf) { + if (!entry || entry[`${key}_at`]) return; + const now = Date.now(); + entry[`${key}_at`] = new Date(now).toISOString(); + entry[`${key}_epoch_ms`] = now; + entry[`${key}_ms`] = rounded(performance.now() - startedPerf); +} + +function finishRequestRecord(entry, startedPerf, updates = {}) { + if (!entry || entry.finished_at) return; + const now = Date.now(); + Object.assign(entry, updates); + entry.finished_at = new Date(now).toISOString(); + entry.finished_epoch_ms = now; + entry.duration_ms = rounded(performance.now() - startedPerf); +} + +function rounded(value) { + return Number(value.toFixed(3)); +} + +function previewText(value) { + return String(value || "").slice(0, 120); +} + +function resetRequestState() { + requestCount = 0; + recentRequests.length = 0; +} + +function applyConfig(updates) { + if (!updates || typeof updates !== "object") return; + assignString(updates, "response_text"); + assignNonNegativeInteger(updates, "first_token_delay_ms"); + assignNonNegativeInteger(updates, "chunk_delay_ms"); + assignNonNegativeInteger(updates, "chunk_count"); + assignNonNegativeInteger(updates, "fail_first_n"); + assignNonNegativeInteger(updates, "fail_every_n"); + assignNonNegativeInteger(updates, "request_log_limit"); + if (updates.fault_status !== undefined) { + const parsed = Number.parseInt(String(updates.fault_status), 10); + if (Number.isInteger(parsed) && parsed >= 400 && parsed <= 599) config.fault_status = parsed; + } + assignBoolean(updates, "fail_after_first_chunk"); + assignBoolean(updates, "dynamic_response"); +} + +function assignString(updates, key) { + if (updates[key] !== undefined) config[key] = String(updates[key]); +} + +function assignNonNegativeInteger(updates, key) { + if (updates[key] === undefined) return; + const parsed = Number.parseInt(String(updates[key]), 10); + if (Number.isInteger(parsed) && parsed >= 0) config[key] = parsed; +} + +function assignBoolean(updates, key) { + if (updates[key] === undefined) return; + config[key] = bool(updates[key], config[key]); +} diff --git a/skills/scripts/e2e/install-qa-plugin-smoke.mjs b/skills/scripts/e2e/install-qa-plugin-smoke.mjs new file mode 100755 index 000000000..73b89af69 --- /dev/null +++ b/skills/scripts/e2e/install-qa-plugin-smoke.mjs @@ -0,0 +1,198 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + apiJson, + ensureEvidence, + evidencePaths, + loadEnvFiles, + resetAndAuthLocalUser, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = env.LBS_CASE_ID || "install-qa-plugin-smoke"; +const paths = evidencePaths(caseId); +await loadEnvFiles(); +await ensureEvidence(paths); + +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const user = env.LANGBOT_E2E_LOGIN_USER || ""; +const password = env.LANGBOT_E2E_LOGIN_PASSWORD || "LangBotE2ELocalPass!2026"; +const packagePath = resolve( + env.LANGBOT_E2E_PLUGIN_PACKAGE + || env.LANGBOT_QA_PLUGIN_SMOKE_PACKAGE + || "skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg", +); +const expectedPluginId = env.LANGBOT_E2E_EXPECTED_PLUGIN_ID || "qa/plugin-smoke"; +const expectedTool = env.LANGBOT_E2E_EXPECTED_TOOL || (expectedPluginId === "qa/plugin-smoke" ? "qa_plugin_echo" : ""); +const expectedRunnerId = env.LANGBOT_E2E_EXPECTED_RUNNER_ID || ""; + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + backend_url: backendUrl, + package_path: packagePath, + package_preview: null, + task_id: null, + task: null, + plugin_present_before: false, + plugin_present_after: false, + tool_names: [], + runner_ids: [], + evidence: { + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["api_diagnostic", "filesystem"], +}; + +try { + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + if (!user) throw new Error("LANGBOT_E2E_LOGIN_USER is required."); + const bytes = await readFile(packagePath); + + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + result.package_preview = await previewPackage(backendUrl, auth.token, bytes, packagePath); + const metadata = result.package_preview.metadata || {}; + if (`${metadata.author}/${metadata.name}` !== expectedPluginId) { + throw new Error(`Fixture package metadata is ${metadata.author}/${metadata.name}, expected ${expectedPluginId}.`); + } + result.plugin_present_before = await hasPlugin(backendUrl, auth.token); + + if (!result.plugin_present_before) { + const form = new FormData(); + form.set("file", new Blob([bytes]), packagePath.split("/").pop()); + const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/plugins/install/local`, { + method: "POST", + headers: { Authorization: `Bearer ${auth.token}` }, + body: form, + }); + const json = await response.json().catch(() => ({})); + if (response.status >= 400 || json.code !== 0) { + throw new Error(json.msg || `Plugin install request failed with HTTP ${response.status}.`); + } + result.task_id = json.data?.task_id ?? null; + if (!result.task_id) throw new Error("Plugin install response did not include task_id."); + result.task = await waitForTask(backendUrl, auth.token, result.task_id); + if (!isTaskComplete(result.task)) { + throw new Error(`Plugin install task did not complete successfully: ${JSON.stringify(result.task)}`); + } + } + + await sleep(1000); + result.plugin_present_after = await hasPlugin(backendUrl, auth.token); + if (!result.plugin_present_after) throw new Error(`${expectedPluginId} is not listed by /api/v1/plugins after install.`); + if (expectedTool) { + result.tool_names = await listToolNames(backendUrl, auth.token); + if (!result.tool_names.includes(expectedTool)) { + throw new Error(`${expectedTool} is not listed by /api/v1/tools after install.`); + } + } + if (expectedRunnerId) { + result.runner_ids = await listRunnerIds(backendUrl, auth.token); + if (!result.runner_ids.includes(expectedRunnerId)) { + throw new Error(`${expectedRunnerId} is not listed by /api/v1/pipelines/_/metadata after install.`); + } + } + + result.status = "pass"; + result.reason = `${expectedPluginId} is installed.`; +} catch (error) { + result.status = "fail"; + result.reason = error.message; +} finally { + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(result.status === "pass" ? 0 : 1); + +async function hasPlugin(backendUrl, token) { + const response = await apiJson(backendUrl, "/api/v1/plugins", { token }); + const plugins = response.json.data?.plugins || []; + return plugins.some((plugin) => { + const metadata = plugin.manifest?.manifest?.metadata || plugin.manifest?.metadata || plugin.metadata || {}; + return `${metadata.author}/${metadata.name}` === expectedPluginId; + }); +} + +async function previewPackage(backendUrl, token, bytes, packagePath) { + const form = new FormData(); + form.set("file", new Blob([bytes]), packagePath.split("/").pop()); + const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/plugins/install/local/preview`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: form, + }); + const json = await response.json().catch(() => ({})); + if (response.status >= 400 || json.code !== 0) { + throw new Error(json.msg || `Plugin package preview failed with HTTP ${response.status}.`); + } + return { + metadata: json.data?.metadata || {}, + component_types: json.data?.component_types || [], + file_count: json.data?.file_count ?? null, + }; +} + +async function listToolNames(backendUrl, token) { + const response = await apiJson(backendUrl, "/api/v1/tools", { token }); + return (response.json.data?.tools || []) + .map((tool) => tool.name || tool.tool_name || tool.function?.name || "") + .filter(Boolean) + .sort(); +} + +async function listRunnerIds(backendUrl, token) { + const response = await apiJson(backendUrl, "/api/v1/pipelines/_/metadata", { token }); + const configs = response.json.data?.configs || []; + return configs + .flatMap((section) => section.stages || []) + .flatMap((stage) => stage.config || []) + .filter((item) => item.name === "id") + .flatMap((item) => item.options || []) + .map((option) => option.name || option.value || option.id || "") + .filter(Boolean) + .sort(); +} + +async function waitForTask(backendUrl, token, taskId) { + const deadline = Date.now() + Number(env.LANGBOT_PLUGIN_INSTALL_TIMEOUT_MS || 120000); + let last = null; + while (Date.now() < deadline) { + const response = await apiJson(backendUrl, `/api/v1/system/tasks/${encodeURIComponent(taskId)}`, { token }); + last = response.json.data || response.json; + if (isTaskComplete(last) || isTaskFailed(last)) return last; + await sleep(1000); + } + return last; +} + +function isTaskComplete(task) { + const status = String(task?.status || task?.state || "").toLowerCase(); + const runtimeStatus = String(task?.runtime?.status || task?.runtime?.state || "").toLowerCase(); + return ["done", "completed", "success", "succeeded", "finished"].includes(status) + || ["done", "completed", "success", "succeeded", "finished"].includes(runtimeStatus) + || task?.done === true + || task?.completed === true + || (task?.runtime?.done === true && !task?.runtime?.exception); +} + +function isTaskFailed(task) { + const status = String(task?.status || task?.state || "").toLowerCase(); + const runtimeStatus = String(task?.runtime?.status || task?.runtime?.state || "").toLowerCase(); + return ["failed", "error", "cancelled", "canceled"].includes(status) + || ["failed", "error", "cancelled", "canceled"].includes(runtimeStatus) + || task?.failed === true + || Boolean(task?.error) + || Boolean(task?.runtime?.exception); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/skills/scripts/e2e/langrag-kb-retrieve.mjs b/skills/scripts/e2e/langrag-kb-retrieve.mjs new file mode 100755 index 000000000..a2b489a60 --- /dev/null +++ b/skills/scripts/e2e/langrag-kb-retrieve.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node + +import { + bodyText, + createBrowser, + ensureEvidence, + evidencePaths, + exitCode, + isLoginUrl, + localIsoWithOffset, + safeScreenshot, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = process.env.LBS_CASE_ID || "langrag-kb-retrieve"; +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const startedAt = new Date(); +const frontendUrl = process.env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = process.env.LANGBOT_BACKEND_URL || ""; +const kbUuid = process.env.LANGBOT_LOCAL_AGENT_RAG_KB_UUID || process.env.LANGBOT_RAG_KB_UUID || ""; +const query = process.env.LANGBOT_E2E_RETRIEVE_QUERY || "What is the local agent runner retrieval sentinel?"; +const expectedText = process.env.LANGBOT_E2E_EXPECTED_TEXT || "azalea-cobalt-7421"; + +let browser; +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + url: "", + kb_uuid: kbUuid, + query, + expected_text: expectedText, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console", "network", "api_diagnostic"], +}; + +try { + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + if (!kbUuid) throw new Error("LANGBOT_LOCAL_AGENT_RAG_KB_UUID or LANGBOT_RAG_KB_UUID is required."); + + browser = await createBrowser(paths); + const { page } = browser; + await page.goto(`${frontendUrl.replace(/\/$/, "")}/home/knowledge`, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + result.url = page.url(); + + const text = await bodyText(page); + if (isLoginUrl(page.url()) || /登录|Login|Sign in/i.test(text)) { + result.status = "blocked"; + result.reason = "Browser profile is not authenticated for LANGBOT_FRONTEND_URL."; + } else if (!/Knowledge|知识库|qa-local-agent-rag/i.test(text)) { + result.status = "fail"; + result.reason = "Knowledge page opened, but no Knowledge UI signal or QA KB name was visible."; + } else { + const retrieve = await page.evaluate(async ({ backendUrl, kbUuid, query }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { status: "blocked", authenticated: false, reason: "Browser profile has no localStorage token." }; + } + const response = await fetch(`${backendUrl}/api/v1/knowledge/bases/${encodeURIComponent(kbUuid)}/retrieve`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query }), + }); + const json = await response.json().catch(() => ({})); + return { + status: response.status >= 400 ? "fail" : "ready", + authenticated: true, + http_status: response.status, + code: json.code ?? null, + msg: json.msg || "", + results: json.data?.results || [], + }; + }, { backendUrl, kbUuid, query }); + + result.retrieve = { + ...retrieve, + results: Array.isArray(retrieve.results) + ? retrieve.results.map((item) => ({ + score: item.score ?? item.distance ?? null, + text: String(item.text || item.content || "").slice(0, 500), + metadata: item.metadata || {}, + })) + : [], + }; + + const resultText = JSON.stringify(result.retrieve.results || []); + if (retrieve.status === "blocked") { + result.status = "blocked"; + result.reason = retrieve.reason || "Retrieve API blocked."; + } else if (retrieve.status === "fail") { + result.status = "fail"; + result.reason = retrieve.msg || "Retrieve API failed."; + } else if (!resultText.includes(expectedText)) { + result.status = "fail"; + result.reason = `Retrieve results did not contain expected text: ${expectedText}`; + } else { + result.status = "pass"; + result.reason = `Knowledge retrieve returned expected sentinel: ${expectedText}`; + } + } + + await safeScreenshot(page, paths.screenshot); +} catch (error) { + result.status = /Playwright is not installed|not configured|required/.test(error.message) ? "env_issue" : "fail"; + result.reason = error.message; +} finally { + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/scripts/e2e/lib/debug-chat.mjs b/skills/scripts/e2e/lib/debug-chat.mjs new file mode 100755 index 000000000..7d44d00c9 --- /dev/null +++ b/skills/scripts/e2e/lib/debug-chat.mjs @@ -0,0 +1,416 @@ +import { + bodyText, + clickFirstVisible, + countOccurrences, + gotoFrontend, + isLoginUrl, +} from "./langbot-e2e.mjs"; + +export const DEBUG_CHAT_FAILURE_SIGNALS = [ + "Agent runner temporarily unavailable", + "All models failed during streaming setup", + "调用超时", + "超时", +]; + +export function minExpectedOccurrences(beforeText, expectedText, prompt) { + const beforeCount = countOccurrences(beforeText, expectedText); + return beforeCount + (String(prompt).includes(expectedText) ? 2 : 1); +} + +export function latestExpectedLeafMatches(latestExpectedLeaf, prompt) { + return Boolean(latestExpectedLeaf) + && latestExpectedLeaf !== prompt + && !String(latestExpectedLeaf).includes(prompt); +} + +export function findNewFailureSignal(beforeText, afterText, failureSignals = DEBUG_CHAT_FAILURE_SIGNALS) { + return failureSignals.find((signal) => countOccurrences(afterText, signal) > countOccurrences(beforeText, signal)) || ""; +} + +function findFailureSignalInText(text, failureSignals = DEBUG_CHAT_FAILURE_SIGNALS) { + return failureSignals.find((signal) => String(text || "").includes(signal)) || ""; +} + +function countExpectedInMessages(messages, expectedText) { + return messages + .filter((message) => message.role === "assistant") + .reduce((count, message) => count + countOccurrences(message.text, expectedText), 0); +} + +function debugChatInput(page) { + return page + .locator('input[placeholder*="message"], input[placeholder*="消息"], textarea[placeholder*="message"], textarea[placeholder*="消息"]') + .last(); +} + +async function clickDebugChatTab(page) { + const tabByRole = page.getByRole("tab", { name: /Debug Chat|调试聊天|调试对话|Debug|调试/i }).first(); + if (await tabByRole.isVisible({ timeout: 3_000 }).catch(() => false)) { + await tabByRole.click(); + return true; + } + + const tabBySelector = page.locator('[role="tab"]').filter({ hasText: /Debug Chat|调试聊天|调试对话|Debug|调试/i }).first(); + if (await tabBySelector.isVisible({ timeout: 2_000 }).catch(() => false)) { + await tabBySelector.click(); + return true; + } + + return Boolean(await clickFirstVisible(page, ["Debug Chat", "调试聊天", "调试对话"], 2_000)); +} + +async function waitForDebugChatReady(page, timeout = 20_000) { + const input = debugChatInput(page); + const visible = await input.isVisible({ timeout }).catch(() => false); + if (!visible) { + return { + ready: false, + reason: "Debug Chat tab was clicked, but the Debug Chat input did not become visible.", + }; + } + + const enabled = await input.isEnabled({ timeout }).catch(() => false); + if (!enabled) { + return { + ready: false, + reason: "Debug Chat input is visible but disabled; WebSocket may not be connected.", + }; + } + + return { ready: true, reason: "" }; +} + +export function classifyDebugChatResult({ + beforeText, + afterText, + expectedText, + prompt, + latestExpectedLeaf, + latestFailureLeaf, + beforeMessages = null, + afterMessages = null, + latestAssistantText = "", + failureSignals = DEBUG_CHAT_FAILURE_SIGNALS, +}) { + const minExpectedCount = minExpectedOccurrences(beforeText, expectedText, prompt); + const finalCount = countOccurrences(afterText, expectedText); + const failureText = findNewFailureSignal(beforeText, afterText, failureSignals); + const promptContainsExpected = String(prompt).includes(expectedText); + const hasMessageEvidence = Array.isArray(beforeMessages) && Array.isArray(afterMessages); + const beforeAssistantExpectedCount = hasMessageEvidence + ? countExpectedInMessages(beforeMessages, expectedText) + : null; + const afterAssistantExpectedCount = hasMessageEvidence + ? countExpectedInMessages(afterMessages, expectedText) + : null; + const assistantExpectedIncreased = hasMessageEvidence + ? afterAssistantExpectedCount > beforeAssistantExpectedCount + : false; + + if (hasMessageEvidence) { + const latestAssistantFailure = findFailureSignalInText(latestAssistantText, failureSignals); + if (latestAssistantFailure) { + return { + status: "fail", + reason: `Debug Chat displayed a known failure signal in the latest assistant message: ${latestAssistantFailure}`, + min_expected_count: minExpectedCount, + final_count: finalCount, + failure_signal: latestAssistantFailure, + before_assistant_expected_count: beforeAssistantExpectedCount, + after_assistant_expected_count: afterAssistantExpectedCount, + }; + } + if (assistantExpectedIncreased && String(latestAssistantText).includes(expectedText)) { + return { + status: "pass", + reason: `Expected text appeared in a new assistant message: ${expectedText}`, + min_expected_count: minExpectedCount, + final_count: finalCount, + before_assistant_expected_count: beforeAssistantExpectedCount, + after_assistant_expected_count: afterAssistantExpectedCount, + }; + } + if (failureText) { + return { + status: "fail", + reason: `Debug Chat displayed a known failure signal: ${failureText}`, + min_expected_count: minExpectedCount, + final_count: finalCount, + failure_signal: failureText, + before_assistant_expected_count: beforeAssistantExpectedCount, + after_assistant_expected_count: afterAssistantExpectedCount, + }; + } + return { + status: "fail", + reason: `Expected text did not appear in a new assistant message. Expected assistant occurrences to increase above ${beforeAssistantExpectedCount}, saw ${afterAssistantExpectedCount}.`, + min_expected_count: minExpectedCount, + final_count: finalCount, + before_assistant_expected_count: beforeAssistantExpectedCount, + after_assistant_expected_count: afterAssistantExpectedCount, + }; + } + if (failureText) { + return { + status: "fail", + reason: `Debug Chat displayed a known failure signal: ${failureText}`, + min_expected_count: minExpectedCount, + final_count: finalCount, + failure_signal: failureText, + before_assistant_expected_count: beforeAssistantExpectedCount, + after_assistant_expected_count: afterAssistantExpectedCount, + }; + } + if (latestExpectedLeafMatches(latestExpectedLeaf, prompt) && finalCount >= minExpectedCount) { + return { + status: "pass", + reason: `Expected text appeared in the latest visible response leaf: ${expectedText}`, + min_expected_count: minExpectedCount, + final_count: finalCount, + }; + } + if (!promptContainsExpected && finalCount >= minExpectedCount) { + return { + status: "pass", + reason: `Expected text appeared enough times for user prompt plus bot response: ${expectedText}`, + min_expected_count: minExpectedCount, + final_count: finalCount, + }; + } + return { + status: "fail", + reason: `Bot response did not appear. Expected ${minExpectedCount} occurrences of ${expectedText}, saw ${finalCount}.`, + min_expected_count: minExpectedCount, + final_count: finalCount, + }; +} + +export async function openPipelineDebugChat(page, { pipelineUrl, pipelineName, envHint = "LANGBOT_PIPELINE_URL or LANGBOT_PIPELINE_NAME" }) { + if (pipelineUrl) { + await page.goto(pipelineUrl, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + } else { + if (!pipelineName) { + return { + opened: false, + status: "blocked", + reason: `Set ${envHint} before running pipeline-debug-chat automation.`, + }; + } + await gotoFrontend(page); + if (isLoginUrl(page.url())) { + return { + opened: false, + status: "blocked", + reason: "Browser profile is not authenticated for LANGBOT_FRONTEND_URL.", + }; + } + const clickedPipelines = await clickFirstVisible(page, ["Pipelines", "流水线"], 4_000); + if (!clickedPipelines) { + return { opened: false, status: "fail", reason: "Could not find Pipelines navigation." }; + } + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + const clickedPipeline = await clickFirstVisible(page, [pipelineName], 5_000); + if (!clickedPipeline) { + return { opened: false, status: "blocked", reason: `Could not find pipeline named ${pipelineName}.` }; + } + } + + if (isLoginUrl(page.url())) { + return { + opened: false, + status: "blocked", + reason: "Browser profile is not authenticated for LANGBOT_FRONTEND_URL.", + }; + } + + const clickedDebug = await clickDebugChatTab(page); + if (!clickedDebug) { + return { opened: false, status: "fail", reason: "Could not find the Debug Chat tab." }; + } + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + const ready = await waitForDebugChatReady(page); + if (!ready.ready) { + return { opened: false, status: "fail", reason: ready.reason }; + } + return { opened: true }; +} + +export async function latestVisibleLeafText(page, needles) { + return await page.evaluate((items) => { + const isVisible = (element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" + && style.display !== "none" + && rect.width > 0 + && rect.height > 0; + }; + const leaves = []; + for (const element of document.body.querySelectorAll("*")) { + if (!isVisible(element)) continue; + const text = element.innerText?.trim(); + if (!text || text.length > 4000) continue; + const visibleChildHasText = Array.from(element.children).some((child) => ( + isVisible(child) && child.innerText?.trim() + )); + if (visibleChildHasText) continue; + if (!items.some((needle) => text.includes(needle))) continue; + leaves.push(text); + } + return leaves.at(-1) || ""; + }, needles); +} + +export async function visibleDebugChatMessages(page) { + return await page.evaluate(() => { + const isVisible = (element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" + && style.display !== "none" + && rect.width > 0 + && rect.height > 0; + }; + const classText = (element) => String(element.getAttribute("class") || ""); + return Array.from(document.querySelectorAll("div.max-w-3xl")) + .filter((element) => isVisible(element)) + .map((element) => { + const row = element.parentElement; + const text = element.innerText?.trim() || ""; + const isUser = classText(element).includes("user-message-bubble") + || classText(row).includes("justify-end"); + return { + role: isUser ? "user" : "assistant", + text, + }; + }) + .filter((message) => message.text); + }); +} + +export async function waitForExpectedDebugChatText(page, { expectedText, minExpectedCount, timeoutMs }) { + await page.waitForFunction( + ({ expected, min }) => { + return document.body.innerText.split(expected).length - 1 >= min; + }, + { expected: expectedText, min: minExpectedCount }, + { timeout: timeoutMs }, + ).catch(() => {}); +} + +export async function waitForDebugChatTextStable(page, { timeoutMs = 5_000, quietMs = 750 } = {}) { + const startedAt = Date.now(); + let lastText = await bodyText(page); + let stableSince = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + await page.waitForTimeout(250); + const currentText = await bodyText(page); + if (currentText !== lastText) { + lastText = currentText; + stableSince = Date.now(); + continue; + } + if (Date.now() - stableSince >= quietMs) return; + } +} + +export async function attachDebugChatImage(page, imagePath) { + if (!imagePath) return { status: "not_required", reason: "" }; + const input = page.locator('input[type="file"][accept*="image"], input[type="file"]').first(); + if (!await input.count()) { + return { status: "fail", reason: "Could not find a Debug Chat image upload input." }; + } + await input.setInputFiles(imagePath); + await page.locator("img").last().waitFor({ state: "visible", timeout: 10_000 }).catch(() => {}); + return { status: "ready", reason: `Attached image fixture: ${imagePath}` }; +} + +export async function sendDebugChatPrompt(page, prompt, imagePath = "") { + const imageResult = await attachDebugChatImage(page, imagePath); + if (imageResult.status === "fail") return imageResult; + + const input = debugChatInput(page); + const inputVisible = await input.isVisible({ timeout: 5_000 }).catch(() => false); + const inputEnabled = inputVisible && await input.isEnabled({ timeout: 10_000 }).catch(() => false); + if (!inputVisible || !inputEnabled) return false; + await input.fill(prompt).catch(async () => { + await input.click(); + await input.pressSequentially(prompt); + }); + const clickedSend = await clickFirstVisible(page, ["Send", "发送", "提交"], 1_500); + if (!clickedSend) await page.keyboard.press("Enter"); + await page.getByText(prompt, { exact: false }).last().waitFor({ state: "visible", timeout: 10_000 }).catch(() => {}); + return true; +} + +export async function runDebugChatPrompt(page, { prompt, expectedText, responseTimeoutMs, imagePath = "", failureSignals = DEBUG_CHAT_FAILURE_SIGNALS }) { + const beforeText = await bodyText(page); + const beforeMessages = await visibleDebugChatMessages(page); + const minExpectedCount = minExpectedOccurrences(beforeText, expectedText, prompt); + const sent = await sendDebugChatPrompt(page, prompt, imagePath); + if (sent !== true) { + if (sent && typeof sent === "object" && typeof sent.reason === "string") return sent; + return { status: "fail", reason: "Could not find a Debug Chat text input." }; + } + + await waitForExpectedDebugChatText(page, { + expectedText, + minExpectedCount, + prompt, + timeoutMs: responseTimeoutMs, + }); + await waitForDebugChatTextStable(page); + + const afterText = await bodyText(page); + const afterMessages = await visibleDebugChatMessages(page); + const latestAssistantText = afterMessages.filter((message) => message.role === "assistant").at(-1)?.text || ""; + const latestExpectedLeaf = await latestVisibleLeafText(page, [expectedText]); + const failureText = findNewFailureSignal(beforeText, afterText, failureSignals); + const latestFailureLeaf = failureText ? await latestVisibleLeafText(page, [failureText]) : ""; + + return classifyDebugChatResult({ + beforeText, + afterText, + expectedText, + prompt, + latestExpectedLeaf, + latestFailureLeaf, + beforeMessages, + afterMessages, + latestAssistantText, + failureSignals, + }); +} + +export async function setDebugChatStreamOutput(page, desired) { + if (desired === null || desired === undefined) return { status: "not_required", reason: "" }; + + const streamSwitch = page.locator('[role="switch"]').first(); + if (!await streamSwitch.isVisible({ timeout: 5_000 }).catch(() => false)) { + return { status: "blocked", reason: "Debug Chat stream switch was not visible." }; + } + if (!await streamSwitch.isEnabled({ timeout: 10_000 }).catch(() => false)) { + return { status: "blocked", reason: "Debug Chat stream switch was visible but disabled." }; + } + + const checked = (await streamSwitch.getAttribute("aria-checked").catch(() => null)) === "true"; + if (checked !== desired) { + await streamSwitch.click(); + await page.waitForFunction( + ({ selector, expected }) => document.querySelector(selector)?.getAttribute("aria-checked") === String(expected), + { selector: '[role="switch"]', expected: desired }, + { timeout: 5_000 }, + ).catch(() => {}); + } + + const finalChecked = (await streamSwitch.getAttribute("aria-checked").catch(() => null)) === "true"; + if (finalChecked !== desired) { + return { + status: "fail", + reason: `Debug Chat stream switch did not reach requested state: ${desired ? "on" : "off"}.`, + }; + } + return { status: "ready", reason: `Debug Chat stream switch is ${desired ? "on" : "off"}.` }; +} diff --git a/skills/scripts/e2e/lib/langbot-e2e.mjs b/skills/scripts/e2e/lib/langbot-e2e.mjs new file mode 100755 index 000000000..a7584c904 --- /dev/null +++ b/skills/scripts/e2e/lib/langbot-e2e.mjs @@ -0,0 +1,342 @@ +import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { env } from "node:process"; + +const secretRe = /(?:authorization|bearer|token|secret|password|api[_-]?key|jwt|oauth)\s*[:=]\s*["']?[^"',\s]+/gi; + +export function redact(text) { + return String(text ?? "") + .replace(secretRe, (match) => match.replace(/[:=]\s*["']?.*$/, "=[redacted]")) + .replace(/\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]") + .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]"); +} + +export function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +export function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + const pad = (value) => String(value).padStart(2, "0"); + const yyyy = date.getFullYear(); + const mm = pad(date.getMonth() + 1); + const dd = pad(date.getDate()); + const hh = pad(date.getHours()); + const mi = pad(date.getMinutes()); + const ss = pad(date.getSeconds()); + const ms = String(date.getMilliseconds()).padStart(3, "0"); + return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}.${ms}${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`; +} + +export function evidencePaths(caseId) { + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join("reports", "evidence", runId)); + return { + runId, + evidenceDir, + consoleLog: join(evidenceDir, "console.log"), + networkLog: join(evidenceDir, "network.log"), + screenshot: join(evidenceDir, "screenshot.png"), + automationResultJson: join(evidenceDir, "automation-result.json"), + resultJson: join(evidenceDir, "result.json"), + }; +} + +export async function ensureEvidence(paths) { + await mkdir(paths.evidenceDir, { recursive: true }); + await appendFile(paths.consoleLog, "", "utf8"); + await appendFile(paths.networkLog, "", "utf8"); +} + +export async function pathExists(path) { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +export async function appendLine(path, line) { + await appendFile(path, `[${localIsoWithOffset()}] ${redact(line)}\n`, "utf8"); +} + +export async function writeResult(paths, result) { + const text = `${JSON.stringify(result, null, 2)}\n`; + if (paths.automationResultJson) await writeFile(paths.automationResultJson, text, "utf8"); + if (paths.resultJson && paths.resultJson !== paths.automationResultJson) { + await writeFile(paths.resultJson, text, "utf8"); + } +} + +export async function loadEnvFiles(paths = ["skills/.env", "skills/.env.local"]) { + const processEnvKeys = new Set(Object.keys(env)); + for (const path of paths) { + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch { + continue; + } + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const equals = trimmed.indexOf("="); + if (equals <= 0) continue; + const key = trimmed.slice(0, equals).trim(); + const value = trimmed.slice(equals + 1).trim().replace(/^["']|["']$/g, ""); + if (!processEnvKeys.has(key)) env[key] = value; + } + } +} + +export async function readRecoveryKey(repo = env.LANGBOT_REPO || "../LangBot") { + const configPath = resolve(repo, "data/config.yaml"); + const config = await readFile(configPath, "utf8"); + const match = config.match(/^\s*recovery_key:\s*['"]?([^'"\s#]+)['"]?\s*$/m); + return match?.[1] || ""; +} + +export async function apiJson(backendUrl, path, { method = "GET", token = "", body } = {}) { + const headers = { "Content-Type": "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + const response = await fetch(`${backendUrl.replace(/\/$/, "")}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; +} + +export async function checkBackendToken(backendUrl, token) { + if (!token) { + return { authenticated: false, http_status: 0, code: null, reason: "No token." }; + } + const response = await apiJson(backendUrl, "/api/v1/user/check-token", { token }); + const code = response.json.code ?? null; + const authenticated = response.status < 400 && code === 0; + return { + authenticated, + http_status: response.status, + code, + reason: authenticated ? "Token accepted by backend." : response.json.msg || "Backend rejected token.", + }; +} + +export async function resetAndAuthLocalUser({ backendUrl, user, password, recoveryKey = "" }) { + const key = recoveryKey || await readRecoveryKey(); + if (!key) throw new Error("Could not read recovery_key from LangBot config."); + + const reset = await apiJson(backendUrl, "/api/v1/user/reset-password", { + method: "POST", + body: { + user, + recovery_key: key, + new_password: password, + }, + }); + if (reset.status >= 400 || reset.json.code !== 0) { + throw new Error(reset.json.msg || `Password reset failed with HTTP ${reset.status}.`); + } + + const auth = await apiJson(backendUrl, "/api/v1/user/auth", { + method: "POST", + body: { user, password }, + }); + const token = auth.json.data?.token || ""; + if (auth.status >= 400 || auth.json.code !== 0 || !token) { + throw new Error(auth.json.msg || `Auth failed with HTTP ${auth.status}.`); + } + + const check = await checkBackendToken(backendUrl, token); + if (!check.authenticated) { + throw new Error(check.reason || "Authenticated token failed backend token check."); + } + + return { token, check }; +} + +export async function setBrowserToken(page, frontendUrl, token) { + await page.addInitScript((value) => { + localStorage.setItem("token", value); + }, token); + await page.goto(frontendUrl, { waitUntil: "domcontentloaded" }); + await page.evaluate((value) => localStorage.setItem("token", value), token); +} + +export async function verifyBrowserToken(page, backendUrl) { + return await page.evaluate(async (baseUrl) => { + const token = localStorage.getItem("token"); + if (!token) { + return { authenticated: false, http_status: 0, code: null, reason: "No localStorage token." }; + } + try { + const response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/v1/user/check-token`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + const json = await response.json().catch(() => ({})); + const code = json.code ?? null; + const authenticated = response.status < 400 && code === 0; + return { + authenticated, + http_status: response.status, + code, + reason: authenticated ? "Token accepted by backend." : json.msg || "Backend rejected token.", + }; + } catch (error) { + return { + authenticated: false, + http_status: 0, + code: null, + reason: error.message, + }; + } + }, backendUrl); +} + +export function exitCode(status) { + if (status === "pass") return 0; + if (status === "blocked" || status === "env_issue") return 2; + return 1; +} + +export async function loadPlaywright() { + try { + return await import("playwright"); + } catch { + throw new Error( + "Playwright is not installed. Install it in this repo with `npm install --save-dev playwright`, then run `npx playwright install chromium`.", + ); + } +} + +export async function createBrowser(paths) { + const { chromium } = await loadPlaywright(); + const headed = env.LBS_HEADED === "1"; + const launchOptions = { + headless: !headed, + }; + if (env.LANGBOT_CHROMIUM_EXECUTABLE && await pathExists(env.LANGBOT_CHROMIUM_EXECUTABLE)) { + launchOptions.executablePath = env.LANGBOT_CHROMIUM_EXECUTABLE; + } + + let browser; + let context; + if (env.LANGBOT_BROWSER_PROFILE) { + context = await chromium.launchPersistentContext(resolve(env.LANGBOT_BROWSER_PROFILE), { + ...launchOptions, + viewport: { width: 1440, height: 960 }, + }); + } else { + browser = await chromium.launch(launchOptions); + context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); + } + const page = context.pages()[0] || await context.newPage(); + + page.on("console", (message) => { + appendLine(paths.consoleLog, `[${message.type()}] ${message.text()}`).catch(() => {}); + }); + page.on("pageerror", (error) => { + appendLine(paths.consoleLog, `[pageerror] ${error.message}`).catch(() => {}); + }); + page.on("requestfailed", (request) => { + appendLine(paths.networkLog, `[requestfailed] ${request.method()} ${request.url()} ${request.failure()?.errorText ?? ""}`).catch(() => {}); + }); + page.on("response", (response) => { + if (response.status() < 400) return; + appendLine(paths.networkLog, `[response] ${response.status()} ${response.url()}`).catch(() => {}); + }); + + return { + page, + context, + async close() { + await context.close(); + if (browser) await browser.close(); + }, + }; +} + +export async function safeScreenshot(page, path) { + try { + await page.screenshot({ path, fullPage: true }); + } catch { + // Screenshot evidence is useful, but a screenshot failure should not hide the real test result. + } +} + +export async function gotoFrontend(page) { + const frontendUrl = env.LANGBOT_FRONTEND_URL; + if (!frontendUrl) { + throw new Error("LANGBOT_FRONTEND_URL is not configured."); + } + await page.goto(frontendUrl, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); +} + +export function isLoginUrl(url) { + return /\/login(?:[/?#]|$)/.test(url); +} + +export async function bodyText(page) { + return await page.locator("body").innerText({ timeout: 5_000 }).catch(() => ""); +} + +export function countOccurrences(haystack, needle) { + if (!needle) return 0; + return String(haystack).split(needle).length - 1; +} + +export async function clickFirstVisible(page, labels, timeout = 2_000) { + for (const label of labels) { + const roleButton = page.getByRole("button", { name: label }).first(); + if (await roleButton.isVisible({ timeout }).catch(() => false)) { + await roleButton.click(); + return label; + } + + const roleLink = page.getByRole("link", { name: label }).first(); + if (await roleLink.isVisible({ timeout }).catch(() => false)) { + await roleLink.click(); + return label; + } + + const text = page.getByText(label, { exact: false }).first(); + if (await text.isVisible({ timeout }).catch(() => false)) { + await text.click(); + return label; + } + } + return null; +} + +export async function fillFirstTextInput(page, value) { + const candidates = [ + page.getByRole("textbox").last(), + page.locator("textarea").last(), + page.locator("[contenteditable=true]").last(), + page.locator("input[type=text]").last(), + ]; + + for (const locator of candidates) { + if (!await locator.isVisible({ timeout: 2_000 }).catch(() => false)) continue; + await locator.fill(value).catch(async () => { + await locator.click(); + await locator.pressSequentially(value); + }); + return true; + } + return false; +} + +export async function waitForVisibleText(page, text, timeout = 20_000) { + await page.getByText(text, { exact: false }).last().waitFor({ state: "visible", timeout }); +} diff --git a/skills/scripts/e2e/local-agent-steering-debug-chat.mjs b/skills/scripts/e2e/local-agent-steering-debug-chat.mjs new file mode 100755 index 000000000..dae2e265c --- /dev/null +++ b/skills/scripts/e2e/local-agent-steering-debug-chat.mjs @@ -0,0 +1,565 @@ +#!/usr/bin/env node + +import { writeFile } from "node:fs/promises"; +import { env } from "node:process"; +import { + DEBUG_CHAT_FAILURE_SIGNALS, + openPipelineDebugChat, + setDebugChatStreamOutput, + visibleDebugChatMessages, + waitForDebugChatTextStable, +} from "./lib/debug-chat.mjs"; +import { + createBrowser, + ensureEvidence, + evidencePaths, + exitCode, + localIsoWithOffset, + loadEnvFiles, + pathExists, + safeScreenshot, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +await loadEnvFiles(); + +const caseId = env.LBS_CASE_ID || "local-agent-steering-debug-chat"; +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const backendUrl = (env.LANGBOT_BACKEND_URL || "").replace(/\/$/, ""); +const pipelineUrl = env.LANGBOT_E2E_PIPELINE_URL || env.LANGBOT_LOCAL_AGENT_PIPELINE_URL || env.LANGBOT_PIPELINE_URL || ""; +const pipelineName = env.LANGBOT_E2E_PIPELINE_NAME || env.LANGBOT_LOCAL_AGENT_PIPELINE_NAME || env.LANGBOT_PIPELINE_NAME || ""; +const expectedRunnerId = env.LANGBOT_E2E_EXPECTED_RUNNER_ID || "plugin:langbot/local-agent/default"; +const expectedText = env.LANGBOT_E2E_EXPECTED_TEXT || "qa_steering_sentinel_6194"; +const responseTimeoutMs = positiveInt(env.LANGBOT_E2E_RESPONSE_TIMEOUT_MS, 240000); +const followupDelayMs = 1000; +const followupEnabledTimeoutMs = 1500; +const firstPrompt = env.LANGBOT_E2E_PROMPT || [ + "You are running the LangBot steering E2E test.", + "First call the qa_plugin_sleep tool with seconds=8 and text=steering-e2e-anchor.", + "Do not answer before the tool result is available.", + "After the tool returns, answer the latest user follow-up.", + "If no follow-up was injected, reply only STEERING_NO_FOLLOWUP.", +].join(" "); +const followupPrompt = [ + "This is a steering follow-up sent while the first tool call is still active.", + `Return only ${expectedText}.`, +].join(" "); + +const pipelineConfigDiagnosticPath = `${paths.evidenceDir}/pipeline-config-diagnostic.json`; +const debugChatResetDiagnosticPath = `${paths.evidenceDir}/debug-chat-reset-diagnostic.json`; +const toolDiagnosticPath = `${paths.evidenceDir}/tool-diagnostic.json`; + +let browser; +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + started_at: new Date().toISOString(), + started_at_local: localIsoWithOffset(new Date()), + url: "", + backend_url: backendUrl, + pipeline_url: pipelineUrl, + pipeline_name: pipelineName, + expected_runner_id: expectedRunnerId, + first_prompt: firstPrompt, + followup_prompt: followupPrompt, + expected_text: expectedText, + followup_delay_ms: followupDelayMs, + followup_enabled_timeout_ms: followupEnabledTimeoutMs, + response_timeout_ms: responseTimeoutMs, + pipeline_config: null, + debug_chat_reset: null, + tool_diagnostic: null, + steering: null, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "console", "network", "screenshot"], +}; + +try { + if (!backendUrl) { + result.status = "env_issue"; + result.reason = "LANGBOT_BACKEND_URL is required."; + throw new Error(result.reason); + } + + browser = await createBrowser(paths); + const { page } = browser; + + const openResult = await openPipelineDebugChat(page, { + pipelineUrl, + pipelineName, + envHint: "case-specific pipeline env mapped to LANGBOT_E2E_PIPELINE_URL or LANGBOT_E2E_PIPELINE_NAME", + }); + result.url = page.url(); + if (!openResult.opened) { + result.status = openResult.status; + result.reason = openResult.reason; + } else { + const pipelineDiagnostic = await inspectPipeline(page, { + backendUrl, + pipelineUrl, + pipelineName, + expectedRunnerId, + }); + await writeFile(pipelineConfigDiagnosticPath, `${JSON.stringify(pipelineDiagnostic, null, 2)}\n`, "utf8"); + result.evidence.pipeline_config_diagnostic_json = pipelineConfigDiagnosticPath; + result.pipeline_config = pipelineDiagnostic; + if (!result.evidence_collected.includes("api_diagnostic")) result.evidence_collected.push("api_diagnostic"); + + const toolDiagnostic = await inspectToolNames(page, { backendUrl }); + await writeFile(toolDiagnosticPath, `${JSON.stringify(toolDiagnostic, null, 2)}\n`, "utf8"); + result.evidence.tool_diagnostic_json = toolDiagnosticPath; + result.tool_diagnostic = toolDiagnostic; + + if (pipelineDiagnostic.status === "fail" || pipelineDiagnostic.status === "blocked") { + result.status = pipelineDiagnostic.status; + result.reason = pipelineDiagnostic.reason || "Pipeline diagnostic failed."; + } else if (toolDiagnostic.status === "fail" || toolDiagnostic.status === "blocked") { + result.status = toolDiagnostic.status; + result.reason = toolDiagnostic.reason || "Tool diagnostic failed."; + } else if (!toolDiagnostic.tool_names.includes("qa_plugin_sleep")) { + result.status = "blocked"; + result.reason = "qa_plugin_sleep is not exposed by /api/v1/tools; rebuild/reinstall qa-plugin-smoke before running steering E2E."; + } else { + const resetDiagnostic = await resetPipelineDebugChat(page, { + backendUrl, + pipelineId: pipelineDiagnostic.pipeline_id, + sessionType: "person", + }); + await writeFile(debugChatResetDiagnosticPath, `${JSON.stringify(resetDiagnostic, null, 2)}\n`, "utf8"); + result.evidence.debug_chat_reset_diagnostic_json = debugChatResetDiagnosticPath; + result.debug_chat_reset = resetDiagnostic; + + if (resetDiagnostic.status === "fail" || resetDiagnostic.status === "blocked") { + result.status = resetDiagnostic.status; + result.reason = resetDiagnostic.reason || "Debug Chat reset failed."; + } else { + await page.waitForTimeout(1000); + const reopenResult = await openPipelineDebugChat(page, { + pipelineUrl, + pipelineName, + envHint: "case-specific pipeline env mapped to LANGBOT_E2E_PIPELINE_URL or LANGBOT_E2E_PIPELINE_NAME", + }); + result.url = page.url(); + if (!reopenResult.opened) { + result.status = reopenResult.status; + result.reason = reopenResult.reason; + } else { + const streamResult = await setDebugChatStreamOutput(page, true); + if (streamResult.status === "blocked" || streamResult.status === "fail") { + result.status = streamResult.status; + result.reason = streamResult.reason; + } else { + result.steering = await runSteeringProbe(page); + result.status = result.steering.status; + result.reason = result.steering.reason; + } + } + } + } + } +} catch (error) { + if (!["env_issue", "blocked", "fail", "pass"].includes(result.status) || !result.reason) { + result.status = /Playwright is not installed|LANGBOT_FRONTEND_URL/.test(error.message) ? "env_issue" : "fail"; + } + result.reason = result.reason || error.message; +} finally { + if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + const existingEvidence = {}; + for (const [key, value] of Object.entries(result.evidence)) { + if (typeof value !== "string") continue; + const isResultFile = value === paths.automationResultJson || value === paths.resultJson; + if (isResultFile || await pathExists(value)) existingEvidence[key] = value; + } + result.evidence = existingEvidence; + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); + +async function runSteeringProbe(page) { + const beforeMessages = await visibleDebugChatMessages(page); + const beforeAssistantCount = countRole(beforeMessages, "assistant"); + const beforeUserCount = countRole(beforeMessages, "user"); + const firstStartedAt = Date.now(); + const firstSend = await sendPrompt(page, firstPrompt, { enabledTimeoutMs: 5000 }); + if (!firstSend.sent) { + return { + status: "fail", + reason: firstSend.reason || "Could not send first Debug Chat prompt.", + first_send: firstSend, + before_assistant_count: beforeAssistantCount, + before_user_count: beforeUserCount, + }; + } + + await page.waitForTimeout(followupDelayMs); + const preFollowupMessages = await visibleDebugChatMessages(page); + const preFollowupAssistantCount = countRole(preFollowupMessages, "assistant"); + const followupStartedAt = Date.now(); + const followupSend = await sendPrompt(page, followupPrompt, { enabledTimeoutMs: followupEnabledTimeoutMs }); + const followupSentAt = Date.now(); + if (!followupSend.sent) { + return { + status: "fail", + reason: followupSend.reason || "Could not send steering follow-up while the first run was active.", + first_send: firstSend, + followup_send: followupSend, + first_to_followup_attempt_ms: followupStartedAt - firstStartedAt, + followup_send_latency_ms: followupSentAt - followupStartedAt, + before_assistant_count: beforeAssistantCount, + pre_followup_assistant_count: preFollowupAssistantCount, + before_user_count: beforeUserCount, + }; + } + + const waitResult = await waitForLatestAssistantContaining(page, { + expectedText, + beforeAssistantCount, + timeoutMs: responseTimeoutMs, + }); + await waitForDebugChatTextStable(page); + const afterMessages = await visibleDebugChatMessages(page); + const afterAssistantCount = countRole(afterMessages, "assistant"); + const afterUserCount = countRole(afterMessages, "user"); + const latestAssistantText = latestRoleText(afterMessages, "assistant"); + const failureSignal = findFailureSignal(latestAssistantText) || findFailureSignal(messagesText(afterMessages)); + const newAssistantCount = afterAssistantCount - beforeAssistantCount; + const newUserCount = afterUserCount - beforeUserCount; + + const base = { + first_send: firstSend, + followup_send: followupSend, + first_to_followup_attempt_ms: followupStartedAt - firstStartedAt, + followup_send_latency_ms: followupSentAt - followupStartedAt, + before_assistant_count: beforeAssistantCount, + pre_followup_assistant_count: preFollowupAssistantCount, + after_assistant_count: afterAssistantCount, + new_assistant_count: newAssistantCount, + before_user_count: beforeUserCount, + after_user_count: afterUserCount, + new_user_count: newUserCount, + latest_assistant_text: latestAssistantText, + assistant_containing_expected_seen: waitResult.seen, + failure_signal: failureSignal, + }; + + if (failureSignal) { + return { + ...base, + status: "fail", + reason: `Debug Chat displayed a known failure signal: ${failureSignal}`, + }; + } + if (!waitResult.seen) { + return { + ...base, + status: "fail", + reason: `No new assistant message contained steering sentinel ${expectedText}.`, + }; + } + if (!latestAssistantText.includes(expectedText)) { + return { + ...base, + status: "fail", + reason: `Latest assistant message did not contain steering sentinel ${expectedText}.`, + }; + } + if (newUserCount < 2) { + return { + ...base, + status: "fail", + reason: `Expected two new user messages, saw ${newUserCount}.`, + }; + } + if (newAssistantCount !== 1) { + return { + ...base, + status: "fail", + reason: `Expected one assistant response for one claimed steering run, saw ${newAssistantCount}. More than one usually means the follow-up became a separate run.`, + }; + } + if (latestAssistantText.includes("STEERING_NO_FOLLOWUP")) { + return { + ...base, + status: "fail", + reason: "Runner answered the no-follow-up branch, so steering was not injected.", + }; + } + + return { + ...base, + status: "pass", + reason: `Follow-up sentinel ${expectedText} appeared in the only new assistant response after two user messages.`, + }; +} + +function debugChatInput(page) { + return page + .locator('input[placeholder*="message"], input[placeholder*="消息"], textarea[placeholder*="message"], textarea[placeholder*="消息"]') + .last(); +} + +async function sendPrompt(page, prompt, { enabledTimeoutMs }) { + const input = debugChatInput(page); + const inputVisible = await input.isVisible({ timeout: 5000 }).catch(() => false); + if (!inputVisible) return { sent: false, reason: "Debug Chat input is not visible." }; + const inputEnabled = await input.isEnabled({ timeout: enabledTimeoutMs }).catch(() => false); + if (!inputEnabled) return { sent: false, reason: `Debug Chat input was not enabled within ${enabledTimeoutMs}ms.` }; + + await input.fill(prompt).catch(async () => { + await input.click(); + await input.pressSequentially(prompt); + }); + await input.press("Enter"); + await page.getByText(prompt, { exact: false }).last().waitFor({ state: "visible", timeout: 10000 }).catch(() => {}); + return { + sent: true, + submitted_by: "keyboard_enter", + }; +} + +async function waitForLatestAssistantContaining(page, { expectedText, beforeAssistantCount, timeoutMs }) { + const deadline = Date.now() + timeoutMs; + let lastMessages = []; + let latestAssistantText = ""; + while (Date.now() < deadline) { + const messages = await visibleDebugChatMessages(page); + lastMessages = messages; + latestAssistantText = latestRoleText(messages, "assistant"); + if (countRole(messages, "assistant") > beforeAssistantCount && latestAssistantText.includes(expectedText)) { + return { + seen: true, + latest_assistant_text: latestAssistantText, + messages, + }; + } + const failureSignal = findFailureSignal(latestAssistantText); + if (failureSignal) { + return { + seen: false, + latest_assistant_text: latestAssistantText, + messages, + failure_signal: failureSignal, + }; + } + await page.waitForTimeout(500); + } + return { + seen: false, + latest_assistant_text: latestAssistantText, + messages: lastMessages, + }; +} + +async function inspectPipeline(page, { backendUrl, pipelineUrl, pipelineName, expectedRunnerId }) { + const pipelineIdFromUrl = pipelineIdFromUrlValue(pipelineUrl); + return await page.evaluate(async ({ backendUrl, pipelineIdFromUrl, pipelineName, expectedRunnerId }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { + status: "blocked", + authenticated: false, + reason: "Browser profile has no localStorage token.", + }; + } + const getJson = async (path) => { + const response = await fetch(`${backendUrl}${path}`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }; + + let pipelineId = pipelineIdFromUrl; + let matchedBy = pipelineId ? "url" : ""; + if (!pipelineId) { + if (!pipelineName) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: false, + reason: "Set LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME.", + }; + } + const list = await getJson("/api/v1/pipelines"); + const pipelines = list.json.data?.pipelines || []; + const match = pipelines.find((pipeline) => pipeline.name === pipelineName); + if (!match) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: false, + list_status: list.status, + reason: `Could not find pipeline named ${pipelineName}.`, + }; + } + pipelineId = match.uuid; + matchedBy = "name"; + } + + const loaded = await getJson(`/api/v1/pipelines/${encodeURIComponent(pipelineId)}`); + const pipeline = loaded.json.data?.pipeline; + if (loaded.status >= 400 || !pipeline) { + return { + status: "fail", + authenticated: true, + pipeline_resolved: false, + pipeline_id: pipelineId, + get_status: loaded.status, + reason: loaded.json.msg || "Could not load pipeline.", + }; + } + const config = pipeline.config || {}; + const runner = config.ai?.runner || {}; + const runnerId = runner.id || runner.runner || ""; + if (!runnerId) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: true, + pipeline_id: pipelineId, + pipeline_name: pipeline.name, + matched_by: matchedBy, + reason: "Pipeline has no ai.runner.id or legacy ai.runner.runner.", + }; + } + if (expectedRunnerId && runnerId !== expectedRunnerId) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: true, + pipeline_id: pipelineId, + pipeline_name: pipeline.name, + matched_by: matchedBy, + runner_id: runnerId, + expected_runner_id: expectedRunnerId, + reason: `Pipeline runner mismatch: expected ${expectedRunnerId}, got ${runnerId}.`, + }; + } + return { + status: "ready", + authenticated: true, + pipeline_resolved: true, + pipeline_id: pipelineId, + pipeline_name: pipeline.name, + matched_by: matchedBy, + runner_id: runnerId, + expected_runner_id: expectedRunnerId || "", + }; + }, { backendUrl, pipelineIdFromUrl, pipelineName, expectedRunnerId }); +} + +async function inspectToolNames(page, { backendUrl }) { + return await page.evaluate(async ({ backendUrl }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { + status: "blocked", + authenticated: false, + tool_names: [], + reason: "Browser profile has no localStorage token.", + }; + } + const response = await fetch(`${backendUrl}/api/v1/tools`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + const json = await response.json().catch(() => ({})); + const toolNames = (json.data?.tools || []) + .map((tool) => tool.name || tool.tool_name || tool.function?.name || "") + .filter(Boolean) + .sort(); + return { + status: response.status >= 400 ? "fail" : "ready", + authenticated: true, + http_status: response.status, + code: json.code ?? null, + tool_names: toolNames, + reason: response.status >= 400 ? json.msg || "Could not list tools." : "Tool list loaded.", + }; + }, { backendUrl }); +} + +async function resetPipelineDebugChat(page, { backendUrl, pipelineId, sessionType }) { + return await page.evaluate(async ({ backendUrl, pipelineId, sessionType }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { + status: "blocked", + authenticated: false, + pipeline_id: pipelineId, + session_type: sessionType, + reason: "Browser profile has no localStorage token.", + }; + } + const response = await fetch( + `${backendUrl}/api/v1/pipelines/${encodeURIComponent(pipelineId)}/ws/reset/${encodeURIComponent(sessionType)}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }, + ); + const json = await response.json().catch(() => ({})); + return { + status: response.status >= 400 ? "fail" : "ready", + authenticated: true, + pipeline_id: pipelineId, + session_type: sessionType, + reset_status: response.status, + reset_code: json.code ?? null, + reason: response.status >= 400 ? json.msg || "Debug Chat reset failed." : "Debug Chat session reset.", + }; + }, { backendUrl, pipelineId, sessionType }); +} + +function pipelineIdFromUrlValue(value) { + const match = String(value || "").match(/\/pipelines?\/([^/?#]+)/i); + return match ? decodeURIComponent(match[1]) : ""; +} + +function countRole(messages, role) { + return messages.filter((message) => message.role === role).length; +} + +function latestRoleText(messages, role) { + return messages.filter((message) => message.role === role).at(-1)?.text || ""; +} + +function messagesText(messages) { + return messages.map((message) => message.text).join("\n"); +} + +function findFailureSignal(text) { + return DEBUG_CHAT_FAILURE_SIGNALS.find((signal) => String(text || "").includes(signal)) || ""; +} + +function positiveInt(value, fallback) { + const parsed = Number.parseInt(String(value || ""), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} diff --git a/skills/scripts/e2e/mcp-stdio-fixture.mjs b/skills/scripts/e2e/mcp-stdio-fixture.mjs new file mode 100755 index 000000000..894101301 --- /dev/null +++ b/skills/scripts/e2e/mcp-stdio-fixture.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + ensureEvidence, + evidencePaths, + exitCode, + localIsoWithOffset, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +function loadEnvDefaults(path) { + if (!existsSync(path)) return; + for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const sep = line.indexOf("="); + if (sep === -1) continue; + const key = line.slice(0, sep).trim(); + if (env[key]) continue; + env[key] = line.slice(sep + 1).trim().replace(/^["']|["']$/g, ""); + } +} + +loadEnvDefaults("skills/.env"); +loadEnvDefaults("skills/.env.local"); + +const caseId = env.LBS_CASE_ID || "mcp-stdio-fixture-direct"; +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const startedAt = new Date(); +const fixturePath = resolve(env.LANGBOT_MCP_FIXTURE_PATH || "skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py"); +const langbotRepo = env.LANGBOT_REPO ? resolve(env.LANGBOT_REPO) : ""; +const uvCandidates = [ + env.LANGBOT_MCP_FIXTURE_UV, + "uv", +].filter(Boolean); +const uv = uvCandidates.find((candidate) => candidate === "uv" || existsSync(candidate)); +const pythonCandidates = [ + env.LANGBOT_MCP_FIXTURE_PYTHON, + langbotRepo ? `${langbotRepo}/.venv/bin/python` : "", + "python3", +].filter(Boolean); +const python = pythonCandidates.find((candidate) => candidate === "python3" || existsSync(candidate)); +const command = langbotRepo && uv + ? { executable: uv, args: ["run", "python", fixturePath], cwd: langbotRepo, mode: "uv" } + : python + ? { executable: python, args: [fixturePath], cwd: resolve("."), mode: "python" } + : null; +const expectedText = "qa_mcp_echo:mcp-stdio-fixture-ok"; + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + fixture_path: fixturePath, + command, + expected_text: expectedText, + evidence: { + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, +}; + +function parseJsonLines(buffer) { + return buffer + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter(Boolean); +} + +async function request(child, id, method, params) { + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); +} + +async function run() { + if (!command) { + result.status = "env_issue"; + result.reason = "No uv or Python interpreter found. Set LANGBOT_REPO, LANGBOT_MCP_FIXTURE_UV, or LANGBOT_MCP_FIXTURE_PYTHON."; + return; + } + if (!existsSync(fixturePath)) { + result.status = "env_issue"; + result.reason = `MCP fixture not found: ${fixturePath}`; + return; + } + + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + + const timeout = setTimeout(() => child.kill("SIGTERM"), 10_000); + try { + await new Promise((resolveReady) => setTimeout(resolveReady, 100)); + await request(child, 1, "initialize", { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "langbot-skills", version: "0" }, + }); + await new Promise((resolveReady) => setTimeout(resolveReady, 200)); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })}\n`); + await request(child, 2, "tools/list", {}); + await request(child, 3, "tools/call", { + name: "qa_mcp_echo", + arguments: { text: "mcp-stdio-fixture-ok" }, + }); + await new Promise((resolveDone) => setTimeout(resolveDone, 1500)); + } finally { + clearTimeout(timeout); + child.kill("SIGTERM"); + } + + const messages = parseJsonLines(stdout); + if (/No module named ['"]mcp['"]|ModuleNotFoundError/i.test(stderr)) { + result.status = "env_issue"; + result.reason = `Python environment cannot import mcp. Set LANGBOT_MCP_FIXTURE_PYTHON to a LangBot venv Python. stderr=${stderr.trim()}`; + return; + } + const listResult = messages.find((message) => message.id === 2)?.result; + const callResult = messages.find((message) => message.id === 3)?.result; + const toolNames = Array.isArray(listResult?.tools) + ? listResult.tools.map((tool) => tool.name) + : []; + const callText = Array.isArray(callResult?.content) + ? callResult.content.map((item) => item.text || "").join("\n") + : ""; + + if (!toolNames.includes("qa_mcp_echo")) { + result.status = "fail"; + result.reason = `MCP fixture did not list qa_mcp_echo. stderr=${stderr.trim()}`; + return; + } + if (!callText.includes(expectedText)) { + result.status = "fail"; + result.reason = `MCP fixture call did not return ${expectedText}. stderr=${stderr.trim()}`; + return; + } + + result.status = "pass"; + result.reason = "MCP stdio fixture listed qa_mcp_echo and returned the deterministic tool result without a model provider."; +} + +try { + await run(); +} catch (error) { + result.status = "fail"; + result.reason = error instanceof Error ? error.message : String(error); +} finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/scripts/e2e/mcp-stdio-register.mjs b/skills/scripts/e2e/mcp-stdio-register.mjs new file mode 100755 index 000000000..e2e31a9ad --- /dev/null +++ b/skills/scripts/e2e/mcp-stdio-register.mjs @@ -0,0 +1,234 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + createBrowser, + ensureEvidence, + evidencePaths, + exitCode, + localIsoWithOffset, + safeScreenshot, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +function loadEnvDefaults(path) { + if (!existsSync(path)) return; + for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const sep = line.indexOf("="); + if (sep === -1) continue; + const key = line.slice(0, sep).trim(); + if (env[key]) continue; + env[key] = line.slice(sep + 1).trim().replace(/^["']|["']$/g, ""); + } +} + +loadEnvDefaults("skills/.env"); +loadEnvDefaults("skills/.env.local"); + +const caseId = env.LBS_CASE_ID || "mcp-stdio-register"; +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const startedAt = new Date(); +const serverName = env.LANGBOT_MCP_SERVER_NAME || "qa-local-stdio"; +const expectedTool = env.LANGBOT_MCP_EXPECTED_TOOL || "qa_mcp_echo"; +const fixturePath = resolve(env.LANGBOT_MCP_FIXTURE_PATH || "skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py"); +const fixtureCommand = env.LANGBOT_MCP_FIXTURE_COMMAND || "python"; +const fixtureArgs = env.LANGBOT_MCP_FIXTURE_ARGS + ? JSON.parse(env.LANGBOT_MCP_FIXTURE_ARGS) + : [fixturePath]; +const startupTimeoutSec = Number(env.LANGBOT_MCP_STARTUP_TIMEOUT_SEC || "300"); +const readyTimeoutMs = Number(env.LANGBOT_MCP_READY_TIMEOUT_MS || "360000"); +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const apiDiagnosticPath = resolve(paths.evidenceDir, "api-diagnostic.json"); + +let browser; +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + server_name: serverName, + fixture_path: fixturePath, + expected_tool: expectedTool, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + api_diagnostic_json: apiDiagnosticPath, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["api_diagnostic"], +}; + +async function run() { + if (!backendUrl) { + result.status = "env_issue"; + result.reason = "LANGBOT_BACKEND_URL is not configured."; + return; + } + if (!existsSync(fixturePath)) { + result.status = "env_issue"; + result.reason = `MCP fixture not found: ${fixturePath}`; + return; + } + + browser = await createBrowser(paths); + const { page } = browser; + await page.goto(env.LANGBOT_FRONTEND_URL, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + + const diagnostic = await page.evaluate(async ({ + backendUrl, + serverName, + expectedTool, + fixturePath, + fixtureCommand, + fixtureArgs, + startupTimeoutSec, + readyTimeoutMs, + }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { + authenticated: false, + save_status: 0, + save_code: null, + save_msg: "Browser profile has no localStorage token.", + tool_names: [], + has_expected_tool: false, + runtime_status: null, + runtime_tool_names: [], + runtime_error: "", + }; + } + + const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + const serverConfig = { + name: serverName, + mode: "stdio", + enable: true, + extra_args: { + command: fixtureCommand, + args: fixtureArgs, + env: {}, + box: { + startup_timeout_sec: startupTimeoutSec, + }, + }, + }; + const getJson = async (path) => { + const response = await fetch(`${backendUrl}${path}`, { headers }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }; + const sendJson = async (method, path, body) => { + const response = await fetch(`${backendUrl}${path}`, { + method, + headers, + body: JSON.stringify(body), + }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }; + + const serverPath = `/api/v1/mcp/servers/${encodeURIComponent(serverName)}`; + const beforeServer = await getJson(serverPath); + const save = beforeServer.status === 404 + ? await sendJson("POST", "/api/v1/mcp/servers", serverConfig) + : await sendJson("PUT", serverPath, serverConfig); + + const deadline = Date.now() + readyTimeoutMs; + let lastTools = []; + let lastRuntime = null; + while (Date.now() < deadline) { + await new Promise((resolveReady) => setTimeout(resolveReady, 500)); + const tools = await getJson("/api/v1/tools"); + const server = await getJson(serverPath); + lastTools = (tools.json.data?.tools || []) + .map((tool) => tool.name || tool.tool_name || tool.function?.name || "") + .filter(Boolean) + .sort(); + lastRuntime = server.json.data?.server?.runtime_info || null; + if (lastTools.includes(expectedTool)) break; + } + + return { + authenticated: true, + before_status: beforeServer.status, + save_status: save.status, + save_code: save.json.code ?? null, + save_msg: save.json.msg || "", + tool_names: lastTools, + has_expected_tool: lastTools.includes(expectedTool), + runtime_status: lastRuntime?.status || null, + runtime_tool_names: (lastRuntime?.tools || []) + .map((tool) => tool.name || tool.tool_name || "") + .filter(Boolean) + .sort(), + runtime_tool_count: lastRuntime?.tool_count ?? null, + runtime_error: lastRuntime?.error_message || "", + }; + }, { backendUrl, serverName, expectedTool, fixturePath, fixtureCommand, fixtureArgs, startupTimeoutSec, readyTimeoutMs }); + + await writeFile(apiDiagnosticPath, `${JSON.stringify(diagnostic, null, 2)}\n`, "utf8"); + await safeScreenshot(page, paths.screenshot); + + if (!diagnostic.authenticated) { + result.status = "blocked"; + result.reason = "Browser profile is not authenticated for LangBot; cannot update MCP server."; + return; + } + if (diagnostic.save_status >= 400 || diagnostic.save_code !== 0) { + result.status = "fail"; + result.reason = `Failed to save MCP server ${serverName}: ${diagnostic.save_status} ${diagnostic.save_msg}`; + return; + } + if (diagnostic.runtime_status !== "connected") { + result.status = "fail"; + result.reason = `MCP server ${serverName} is not connected after save: ${diagnostic.runtime_status || "missing runtime"}. ${diagnostic.runtime_error}`; + return; + } + if (!diagnostic.has_expected_tool || !diagnostic.runtime_tool_names.includes(expectedTool)) { + result.status = "fail"; + result.reason = `MCP server ${serverName} did not expose ${expectedTool}. See ${apiDiagnosticPath}.`; + return; + } + + result.status = "pass"; + result.reason = `MCP server ${serverName} is connected and exposes ${expectedTool} through LangBot /api/v1/tools.`; +} + +try { + await run(); +} catch (error) { + result.status = /Playwright is not installed|LANGBOT_FRONTEND_URL/.test(error.message) ? "env_issue" : "fail"; + result.reason = error instanceof Error ? error.message : String(error); +} finally { + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/scripts/e2e/pipeline-debug-chat.mjs b/skills/scripts/e2e/pipeline-debug-chat.mjs new file mode 100755 index 000000000..4b20f7757 --- /dev/null +++ b/skills/scripts/e2e/pipeline-debug-chat.mjs @@ -0,0 +1,806 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + openPipelineDebugChat, + runDebugChatPrompt, + setDebugChatStreamOutput, +} from "./lib/debug-chat.mjs"; +import { + createBrowser, + ensureEvidence, + evidencePaths, + exitCode, + localIsoWithOffset, + pathExists, + safeScreenshot, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = env.LBS_CASE_ID || "pipeline-debug-chat"; +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const expectedText = env.LANGBOT_E2E_EXPECTED_TEXT || "OK"; +const prompt = env.LANGBOT_E2E_PROMPT || `请只回复 ${expectedText},用于前端调试测试。`; +const responseTimeoutMs = Number.parseInt(env.LANGBOT_E2E_RESPONSE_TIMEOUT_MS || "120000", 10); +const safeResponseTimeoutMs = Number.isFinite(responseTimeoutMs) && responseTimeoutMs > 0 ? responseTimeoutMs : 120000; +const streamOutput = /^(0|false)$/i.test(env.LANGBOT_E2E_STREAM_OUTPUT || "") + ? false + : /^(1|true)$/i.test(env.LANGBOT_E2E_STREAM_OUTPUT || "") + ? true + : null; +const failureSignals = (env.LANGBOT_E2E_FAILURE_SIGNALS || "") + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean); +const imageBase64Path = env.LANGBOT_E2E_IMAGE_BASE64_PATH || ""; +const imagePathEnv = env.LANGBOT_E2E_IMAGE_PATH || ""; +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const pipelineRequired = env.LANGBOT_E2E_PIPELINE_REQUIRED === "1"; +const pipelineUrl = pipelineRequired + ? env.LANGBOT_E2E_PIPELINE_URL + : (env.LANGBOT_E2E_PIPELINE_URL || env.LANGBOT_PIPELINE_URL); +const pipelineName = pipelineRequired + ? env.LANGBOT_E2E_PIPELINE_NAME + : (env.LANGBOT_E2E_PIPELINE_NAME || env.LANGBOT_PIPELINE_NAME); +const expectedRunnerId = env.LANGBOT_E2E_EXPECTED_RUNNER_ID || ""; +const resetDebugChat = boolFromEnv(env.LANGBOT_E2E_RESET_DEBUG_CHAT, false); +const restoreRunnerConfig = boolFromEnv(env.LANGBOT_E2E_RESTORE_RUNNER_CONFIG, true); +const debugChatSessionType = env.LANGBOT_E2E_DEBUG_CHAT_SESSION_TYPE || "person"; +const pipelineConfigDiagnosticPath = resolve(paths.evidenceDir, "pipeline-config-diagnostic.json"); +const debugChatResetDiagnosticPath = resolve(paths.evidenceDir, "debug-chat-reset-diagnostic.json"); +const pipelineConfigRestoreDiagnosticPath = resolve(paths.evidenceDir, "pipeline-config-restore-diagnostic.json"); +const metricsPath = resolve(paths.evidenceDir, "metrics.json"); +const startedAt = new Date(); + +let browser; +let restorePlan = null; +let result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + url: "", + prompt, + expected_text: expectedText, + response_timeout_ms: safeResponseTimeoutMs, + stream_output: streamOutput, + image_fixture: imageBase64Path || imagePathEnv, + prompt_count: 1, + chat_results: [], + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + metrics_json: metricsPath, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console", "network", "metrics"], +}; + +function boolFromEnv(value, defaultValue) { + if (value === undefined || value === "") return defaultValue; + if (/^(0|false|no|off)$/i.test(value)) return false; + if (/^(1|true|yes|on)$/i.test(value)) return true; + return defaultValue; +} + +function parseJsonEnv(key, fallback) { + const raw = env[key]; + if (!raw) return fallback; + try { + return JSON.parse(raw); + } catch (error) { + throw new Error(`${key} must be valid JSON: ${error.message}`); + } +} + +function positiveNumberEnv(key, fallback) { + const value = Number(env[key] || ""); + return Number.isFinite(value) && value >= 0 ? value : fallback; +} + +function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1); + return Number(sorted[index].toFixed(3)); +} + +function stats(values) { + if (values.length === 0) return { min: 0, p50: 0, p95: 0, p99: 0, max: 0 }; + return { + min: Number(Math.min(...values).toFixed(3)), + p50: percentile(values, 50), + p95: percentile(values, 95), + p99: percentile(values, 99), + max: Number(Math.max(...values).toFixed(3)), + }; +} + +function promptStepsFromEnv() { + const rawSteps = parseJsonEnv("LANGBOT_E2E_PROMPTS_JSON", null); + if (rawSteps === null) { + return [{ prompt, expectedText, responseTimeoutMs: safeResponseTimeoutMs }]; + } + if (!Array.isArray(rawSteps) || rawSteps.length === 0) { + throw new Error("LANGBOT_E2E_PROMPTS_JSON must be a non-empty JSON array."); + } + return rawSteps.map((item, index) => { + if (typeof item === "string") { + return { prompt: item, expectedText, responseTimeoutMs: safeResponseTimeoutMs }; + } + if (!item || typeof item !== "object" || typeof item.prompt !== "string" || !item.prompt) { + throw new Error(`LANGBOT_E2E_PROMPTS_JSON[${index}] must be a string or an object with a prompt string.`); + } + const stepTimeout = Number.parseInt(String(item.response_timeout_ms || item.responseTimeoutMs || safeResponseTimeoutMs), 10); + return { + prompt: item.prompt, + expectedText: String(item.expected_text || item.expectedText || expectedText), + responseTimeoutMs: Number.isFinite(stepTimeout) && stepTimeout > 0 ? stepTimeout : safeResponseTimeoutMs, + }; + }); +} + +function expandEnvRefs(value) { + return String(value || "").replace(/\$\{([A-Z][A-Z0-9_]*)\}|\$([A-Z][A-Z0-9_]*)/g, (_match, braced, bare) => { + return env[braced || bare] || ""; + }); +} + +function textList(value) { + if (value === undefined || value === null || value === "") return []; + return Array.isArray(value) ? value.map(String) : [String(value)]; +} + +function runArgv(argv, { cwd = "", timeoutMs = 30_000 } = {}) { + return new Promise((resolveRun) => { + if (!Array.isArray(argv) || argv.length === 0 || !argv.every((item) => typeof item === "string" && item)) { + resolveRun({ + status: "fail", + reason: "Filesystem command check requires a non-empty argv string array.", + exit_code: null, + stdout: "", + stderr: "", + }); + return; + } + + const child = spawn(argv[0], argv.slice(1), { + cwd: cwd ? resolve(cwd) : undefined, + env, + shell: false, + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + }, timeoutMs); + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => { + clearTimeout(timer); + resolveRun({ + status: "fail", + reason: error.message, + exit_code: null, + stdout, + stderr, + }); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolveRun({ + status: timedOut ? "fail" : "pass", + reason: timedOut ? `Command timed out after ${timeoutMs} ms.` : "", + exit_code: code, + stdout, + stderr, + }); + }); + }); +} + +async function runFilesystemChecks(checks) { + if (!Array.isArray(checks) || checks.length === 0) { + return { status: "not_required", checks: [] }; + } + const results = []; + for (let index = 0; index < checks.length; index += 1) { + const check = checks[index]; + if (!check || typeof check !== "object") { + results.push({ index, status: "fail", reason: "Filesystem check must be an object." }); + continue; + } + const contains = textList(check.contains); + const notContains = textList(check.not_contains || check.notContains); + const expectedExitCode = Number.isInteger(check.exit_code) + ? check.exit_code + : Number.isInteger(check.expected_exit_code) + ? check.expected_exit_code + : 0; + const expectedStdout = textList(check.stdout_contains || check.expected_stdout || check.expectedStdout); + + if (check.path) { + const path = resolve(expandEnvRefs(check.path)); + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch (error) { + results.push({ index, status: "fail", type: "file", path, reason: error.message }); + continue; + } + const missing = contains.filter((needle) => !text.includes(needle)); + const forbidden = notContains.filter((needle) => text.includes(needle)); + results.push({ + index, + status: missing.length || forbidden.length ? "fail" : "pass", + type: "file", + path, + missing, + forbidden, + reason: missing.length + ? `Missing expected text: ${missing.join(", ")}` + : forbidden.length + ? `Found forbidden text: ${forbidden.join(", ")}` + : "", + }); + continue; + } + + if (check.argv) { + const cwd = check.cwd ? expandEnvRefs(check.cwd) : ""; + const timeoutMs = Number.parseInt(String(check.timeout_ms || check.timeoutMs || "30000"), 10); + const run = await runArgv(check.argv.map(expandEnvRefs), { + cwd, + timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 30_000, + }); + const missingStdout = expectedStdout.filter((needle) => !run.stdout.includes(needle)); + const exitMatches = run.exit_code === expectedExitCode; + results.push({ + index, + status: run.status === "pass" && exitMatches && missingStdout.length === 0 ? "pass" : "fail", + type: "command", + argv: check.argv, + cwd, + exit_code: run.exit_code, + expected_exit_code: expectedExitCode, + missing_stdout: missingStdout, + stdout_preview: run.stdout.slice(0, 2000), + stderr_preview: run.stderr.slice(0, 2000), + reason: run.reason + || (!exitMatches ? `Expected exit code ${expectedExitCode}, saw ${run.exit_code}.` : "") + || (missingStdout.length ? `Missing stdout text: ${missingStdout.join(", ")}` : ""), + }); + continue; + } + + results.push({ index, status: "fail", reason: "Filesystem check requires either path or argv." }); + } + const failed = results.filter((item) => item.status !== "pass"); + return { + status: failed.length ? "fail" : "pass", + checks: results, + reason: failed.length ? `Filesystem checks failed: ${failed.map((item) => item.index).join(", ")}` : "", + }; +} + +function pipelineIdFromUrl(url) { + if (!url) return ""; + try { + const parsed = new URL(url); + return parsed.searchParams.get("id") || ""; + } catch { + return ""; + } +} + +function sanitizePipelineDiagnostic(diagnostic) { + const { restore_config: _restoreConfig, ...safe } = diagnostic || {}; + return safe; +} + +async function prepareImageFixture(paths) { + if (imagePathEnv) return resolve(imagePathEnv); + if (!imageBase64Path) return ""; + const source = resolve(imageBase64Path); + const target = resolve(paths.evidenceDir, "image-fixture.png"); + const encoded = await readFile(source, "utf8"); + await writeFile(target, Buffer.from(encoded.replace(/\s+/g, ""), "base64")); + return target; +} + +async function inspectAndPatchPipelineConfig(page, { + backendUrl, + pipelineUrl, + pipelineName, + runnerConfigPatch, + expectedRunnerId, +}) { + const pipelineIdFromUrlValue = pipelineIdFromUrl(pipelineUrl) || pipelineIdFromUrl(page.url()); + return await page.evaluate(async ({ + backendUrl, + pipelineIdFromUrlValue, + pipelineName, + runnerConfigPatch, + expectedRunnerId, + }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { + status: "blocked", + authenticated: false, + reason: "Browser profile has no localStorage token.", + }; + } + + const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + const getJson = async (path) => { + const response = await fetch(`${backendUrl}${path}`, { headers }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }; + const putJson = async (path, body) => { + const response = await fetch(`${backendUrl}${path}`, { + method: "PUT", + headers, + body: JSON.stringify(body), + }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }; + + let pipelineId = pipelineIdFromUrlValue || ""; + let matchedBy = pipelineId ? "url" : ""; + if (!pipelineId && pipelineName) { + const list = await getJson("/api/v1/pipelines"); + const pipelines = list.json.data?.pipelines || []; + const match = pipelines.find((pipeline) => pipeline.name === pipelineName); + if (!match) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: false, + list_status: list.status, + reason: `Could not find pipeline named ${pipelineName}.`, + }; + } + pipelineId = match.uuid; + matchedBy = "name"; + } + + if (!pipelineId) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: false, + reason: "Could not resolve pipeline id from URL or pipeline name.", + }; + } + + const before = await getJson(`/api/v1/pipelines/${encodeURIComponent(pipelineId)}`); + const pipeline = before.json.data?.pipeline; + if (before.status >= 400 || !pipeline) { + return { + status: "fail", + authenticated: true, + pipeline_resolved: false, + pipeline_id: pipelineId, + get_status: before.status, + reason: before.json.msg || "Could not load pipeline.", + }; + } + + const config = JSON.parse(JSON.stringify(pipeline.config || {})); + const aiConfig = config.ai && typeof config.ai === "object" ? config.ai : {}; + const runner = aiConfig.runner && typeof aiConfig.runner === "object" ? aiConfig.runner : {}; + const runnerId = runner.id || runner.runner || ""; + if (!runnerId) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: true, + pipeline_id: pipelineId, + pipeline_name: pipeline.name, + matched_by: matchedBy, + reason: "Pipeline has no ai.runner.id or legacy ai.runner.runner.", + }; + } + if (expectedRunnerId && runnerId !== expectedRunnerId) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: true, + pipeline_id: pipelineId, + pipeline_name: pipeline.name, + matched_by: matchedBy, + runner_id: runnerId, + expected_runner_id: expectedRunnerId, + reason: `Pipeline runner mismatch: expected ${expectedRunnerId}, got ${runnerId}.`, + }; + } + + const runnerConfigs = aiConfig.runner_config && typeof aiConfig.runner_config === "object" + ? aiConfig.runner_config + : {}; + const currentRunnerConfig = runnerConfigs[runnerId] && typeof runnerConfigs[runnerId] === "object" + ? runnerConfigs[runnerId] + : {}; + const patchKeys = Object.keys(runnerConfigPatch || {}); + const baseDiagnostic = { + status: "ready", + authenticated: true, + pipeline_resolved: true, + pipeline_id: pipelineId, + pipeline_name: pipeline.name, + matched_by: matchedBy, + runner_id: runnerId, + expected_runner_id: expectedRunnerId || "", + patch_keys: patchKeys, + runner_config_before_keys: Object.keys(currentRunnerConfig), + patched: patchKeys.length > 0, + }; + + if (patchKeys.length === 0) { + return baseDiagnostic; + } + + const updatedRunnerConfig = { + ...currentRunnerConfig, + ...runnerConfigPatch, + }; + const updatedConfig = { + ...config, + ai: { + ...aiConfig, + runner: { + ...runner, + id: runnerId, + }, + runner_config: { + ...runnerConfigs, + [runnerId]: updatedRunnerConfig, + }, + }, + }; + + const update = await putJson(`/api/v1/pipelines/${encodeURIComponent(pipelineId)}`, { + config: updatedConfig, + }); + if (update.status >= 400) { + return { + ...baseDiagnostic, + status: "fail", + put_status: update.status, + put_code: update.json.code ?? null, + reason: update.json.msg || "Pipeline config update failed.", + }; + } + + return { + ...baseDiagnostic, + put_status: update.status, + put_code: update.json.code ?? null, + runner_config_after_keys: Object.keys(updatedRunnerConfig), + restore_config: config, + }; + }, { + backendUrl, + pipelineIdFromUrlValue, + pipelineName, + runnerConfigPatch, + expectedRunnerId, + }); +} + +async function restorePipelineConfig(page, { backendUrl, pipelineId, config }) { + return await page.evaluate(async ({ backendUrl, pipelineId, config }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { + status: "blocked", + authenticated: false, + pipeline_id: pipelineId, + reason: "Browser profile has no localStorage token.", + }; + } + const response = await fetch(`${backendUrl}/api/v1/pipelines/${encodeURIComponent(pipelineId)}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ config }), + }); + const json = await response.json().catch(() => ({})); + return { + status: response.status >= 400 ? "fail" : "ready", + authenticated: true, + pipeline_id: pipelineId, + put_status: response.status, + put_code: json.code ?? null, + reason: response.status >= 400 ? json.msg || "Pipeline config restore failed." : "Pipeline config restored.", + }; + }, { backendUrl, pipelineId, config }); +} + +async function resetPipelineDebugChat(page, { backendUrl, pipelineId, sessionType }) { + return await page.evaluate(async ({ backendUrl, pipelineId, sessionType }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { + status: "blocked", + authenticated: false, + pipeline_id: pipelineId, + session_type: sessionType, + reason: "Browser profile has no localStorage token.", + }; + } + const response = await fetch( + `${backendUrl}/api/v1/pipelines/${encodeURIComponent(pipelineId)}/ws/reset/${encodeURIComponent(sessionType)}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }, + ); + const json = await response.json().catch(() => ({})); + return { + status: response.status >= 400 ? "fail" : "ready", + authenticated: true, + pipeline_id: pipelineId, + session_type: sessionType, + reset_status: response.status, + reset_code: json.code ?? null, + reason: response.status >= 400 ? json.msg || "Debug Chat reset failed." : "Debug Chat session reset.", + }; + }, { backendUrl, pipelineId, sessionType }); +} + +try { + browser = await createBrowser(paths); + const { page } = browser; + const imagePath = await prepareImageFixture(paths); + const promptSteps = promptStepsFromEnv(); + const filesystemChecks = parseJsonEnv("LANGBOT_E2E_FILESYSTEM_CHECKS_JSON", []); + const runnerConfigPatch = parseJsonEnv("LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON", {}); + const runnerPatchKeys = Object.keys(runnerConfigPatch); + if (runnerPatchKeys.length > 0 || resetDebugChat || expectedRunnerId) { + if (!backendUrl) { + result.status = "env_issue"; + result.reason = "LANGBOT_BACKEND_URL is required for runner config patch, runner assertion, or Debug Chat reset."; + throw new Error(result.reason); + } + } + result.prompt_count = promptSteps.length; + result.prompt = promptSteps.length === 1 ? promptSteps[0].prompt : `${promptSteps.length} prompts`; + result.expected_text = promptSteps.at(-1)?.expectedText || expectedText; + + const openResult = await openPipelineDebugChat(page, { + pipelineUrl, + pipelineName, + envHint: pipelineRequired + ? "case-specific pipeline env mapped to LANGBOT_E2E_PIPELINE_URL or LANGBOT_E2E_PIPELINE_NAME" + : "LANGBOT_PIPELINE_URL or LANGBOT_PIPELINE_NAME", + }); + result.url = page.url(); + + if (!openResult.opened) { + result.status = openResult.status; + result.reason = openResult.reason; + } else { + result.status = "running"; + result.reason = ""; + if (runnerPatchKeys.length > 0 || resetDebugChat || expectedRunnerId) { + const pipelineDiagnostic = await inspectAndPatchPipelineConfig(page, { + backendUrl, + pipelineUrl, + pipelineName, + runnerConfigPatch, + expectedRunnerId, + }); + const safeDiagnostic = sanitizePipelineDiagnostic(pipelineDiagnostic); + await writeFile(pipelineConfigDiagnosticPath, `${JSON.stringify(safeDiagnostic, null, 2)}\n`, "utf8"); + result.evidence.pipeline_config_diagnostic_json = pipelineConfigDiagnosticPath; + result.pipeline_config = safeDiagnostic; + if (!result.evidence_collected.includes("api_diagnostic")) result.evidence_collected.push("api_diagnostic"); + + if (pipelineDiagnostic.status === "fail" || pipelineDiagnostic.status === "blocked") { + result.status = pipelineDiagnostic.status; + result.reason = pipelineDiagnostic.reason || "Pipeline config preparation failed."; + } else { + if (pipelineDiagnostic.restore_config && restoreRunnerConfig) { + restorePlan = { + backendUrl, + pipelineId: pipelineDiagnostic.pipeline_id, + config: pipelineDiagnostic.restore_config, + }; + } + if (resetDebugChat) { + const resetDiagnostic = await resetPipelineDebugChat(page, { + backendUrl, + pipelineId: pipelineDiagnostic.pipeline_id, + sessionType: debugChatSessionType, + }); + await writeFile(debugChatResetDiagnosticPath, `${JSON.stringify(resetDiagnostic, null, 2)}\n`, "utf8"); + result.evidence.debug_chat_reset_diagnostic_json = debugChatResetDiagnosticPath; + result.debug_chat_reset = resetDiagnostic; + if (resetDiagnostic.status === "fail" || resetDiagnostic.status === "blocked") { + result.status = resetDiagnostic.status; + result.reason = resetDiagnostic.reason || "Debug Chat reset failed."; + } else { + await page.waitForTimeout(1000); + const reopenResult = await openPipelineDebugChat(page, { + pipelineUrl, + pipelineName, + envHint: pipelineRequired + ? "case-specific pipeline env mapped to LANGBOT_E2E_PIPELINE_URL or LANGBOT_E2E_PIPELINE_NAME" + : "LANGBOT_PIPELINE_URL or LANGBOT_PIPELINE_NAME", + }); + result.url = page.url(); + if (!reopenResult.opened) { + result.status = reopenResult.status; + result.reason = reopenResult.reason; + } + } + } + } + } + + if (result.status === "fail" || result.status === "blocked" || result.status === "env_issue") { + // Preparation already determined the outcome. + } else { + const streamResult = await setDebugChatStreamOutput(page, streamOutput); + if (streamResult.status === "blocked" || streamResult.status === "fail") { + result.status = streamResult.status; + result.reason = streamResult.reason; + } else { + for (let index = 0; index < promptSteps.length; index += 1) { + const step = promptSteps[index]; + const promptStartedAt = Date.now(); + const chatResult = await runDebugChatPrompt(page, { + prompt: step.prompt, + expectedText: step.expectedText, + responseTimeoutMs: step.responseTimeoutMs, + imagePath: index === 0 ? imagePath : "", + failureSignals: failureSignals.length > 0 ? failureSignals : undefined, + }); + const promptDurationMs = Date.now() - promptStartedAt; + result.chat_results.push({ + index, + expected_text: step.expectedText, + status: chatResult.status, + reason: chatResult.reason, + response_duration_ms: promptDurationMs, + min_expected_count: chatResult.min_expected_count, + final_count: chatResult.final_count, + before_assistant_expected_count: chatResult.before_assistant_expected_count, + after_assistant_expected_count: chatResult.after_assistant_expected_count, + failure_signal: chatResult.failure_signal || "", + }); + result.status = chatResult.status; + result.reason = `Prompt ${index + 1}/${promptSteps.length}: ${chatResult.reason}`; + if (chatResult.status !== "pass") break; + } + } + } + + if (result.status === "pass" && filesystemChecks.length > 0) { + const filesystemResult = await runFilesystemChecks(filesystemChecks); + result.filesystem_checks = filesystemResult; + if (!result.evidence_collected.includes("filesystem")) result.evidence_collected.push("filesystem"); + if (filesystemResult.status === "fail") { + result.status = "fail"; + result.reason = filesystemResult.reason || "Filesystem checks failed."; + } + } + } +} catch (error) { + if (!["env_issue", "blocked", "fail", "pass"].includes(result.status) || !result.reason) { + result.status = /Playwright is not installed|LANGBOT_FRONTEND_URL/.test(error.message) ? "env_issue" : "fail"; + } + result.reason = result.reason || error.message; +} finally { + if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); + if (browser?.page && restorePlan) { + const restoreDiagnostic = await restorePipelineConfig(browser.page, restorePlan).catch((error) => ({ + status: "fail", + pipeline_id: restorePlan.pipelineId, + reason: error.message, + })); + await writeFile(pipelineConfigRestoreDiagnosticPath, `${JSON.stringify(restoreDiagnostic, null, 2)}\n`, "utf8"); + result.evidence.pipeline_config_restore_diagnostic_json = pipelineConfigRestoreDiagnosticPath; + result.pipeline_config_restore = restoreDiagnostic; + } + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + const responseDurations = result.chat_results + .map((item) => item.response_duration_ms) + .filter((value) => Number.isFinite(value)); + const passedPrompts = result.chat_results.filter((item) => item.status === "pass").length; + const attemptedPrompts = result.chat_results.length; + const errorRate = attemptedPrompts === 0 ? 1 : Number(((attemptedPrompts - passedPrompts) / attemptedPrompts).toFixed(4)); + const responseStats = stats(responseDurations); + const responseP95BudgetMs = positiveNumberEnv( + "LANGBOT_E2E_DEBUG_CHAT_RESPONSE_P95_MS", + positiveNumberEnv("LANGBOT_DEBUG_CHAT_RESPONSE_P95_MS", safeResponseTimeoutMs), + ); + const maxErrorRate = positiveNumberEnv("LANGBOT_E2E_DEBUG_CHAT_MAX_ERROR_RATE", 0); + const metrics = { + probe: caseId, + url: result.url, + prompt_count: result.prompt_count, + attempted_prompt_count: attemptedPrompts, + passed_prompt_count: passedPrompts, + error_rate: errorRate, + response_duration_ms: responseStats, + total_duration_ms: result.duration_ms, + chat_results: result.chat_results, + }; + result.metrics_summary = { + prompt_count: metrics.prompt_count, + attempted_prompt_count: metrics.attempted_prompt_count, + passed_prompt_count: metrics.passed_prompt_count, + error_rate: metrics.error_rate, + response_p50_ms: metrics.response_duration_ms.p50, + response_p95_ms: metrics.response_duration_ms.p95, + total_duration_ms: metrics.total_duration_ms, + }; + result.thresholds_summary = { + response_p95_ms: { + actual: metrics.response_duration_ms.p95, + max: responseP95BudgetMs, + pass: attemptedPrompts > 0 && metrics.response_duration_ms.p95 <= responseP95BudgetMs, + }, + error_rate: { + actual: metrics.error_rate, + max: maxErrorRate, + pass: metrics.error_rate <= maxErrorRate, + }, + }; + await writeFile(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`, "utf8"); + if (result.status === "pass" && !Object.values(result.thresholds_summary).every((item) => item.pass)) { + result.status = "fail"; + result.reason = "Debug Chat performance breached response latency or error-rate thresholds."; + } + const existingEvidence = {}; + for (const [key, value] of Object.entries(result.evidence)) { + if (typeof value !== "string") continue; + const isResultFile = value === paths.automationResultJson || value === paths.resultJson; + if (isResultFile || await pathExists(value)) existingEvidence[key] = value; + } + result.evidence = existingEvidence; + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/scripts/e2e/refresh-local-login.mjs b/skills/scripts/e2e/refresh-local-login.mjs new file mode 100755 index 000000000..66b4f34c6 --- /dev/null +++ b/skills/scripts/e2e/refresh-local-login.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +import { env } from "node:process"; +import { + bodyText, + createBrowser, + ensureEvidence, + evidencePaths, + loadEnvFiles, + resetAndAuthLocalUser, + safeScreenshot, + setBrowserToken, + verifyBrowserToken, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = "refresh-local-login"; +const paths = evidencePaths(caseId); +await loadEnvFiles(); +await ensureEvidence(paths); + +const result = { + source: "automation", + case_id: caseId, + status: "fail", + reason: "", + user: env.LANGBOT_E2E_LOGIN_USER || "", + frontend_url: env.LANGBOT_FRONTEND_URL || "", + backend_url: env.LANGBOT_BACKEND_URL || "", + backend_token_check: null, + browser_token_check: null, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console", "api_diagnostic"], +}; + +let browser; + +try { + const backendUrl = env.LANGBOT_BACKEND_URL; + const frontendUrl = env.LANGBOT_FRONTEND_URL; + const user = env.LANGBOT_E2E_LOGIN_USER; + const password = env.LANGBOT_E2E_LOGIN_PASSWORD || "LangBotE2ELocalPass!2026"; + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!user) throw new Error("LANGBOT_E2E_LOGIN_USER is required."); + + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + result.backend_token_check = auth.check; + + browser = await createBrowser(paths); + const { page } = browser; + await setBrowserToken(page, frontendUrl, auth.token); + const browserCheck = await verifyBrowserToken(page, backendUrl); + result.browser_token_check = browserCheck; + if (!browserCheck.authenticated) { + throw new Error(browserCheck.reason || "Browser token check failed."); + } + + await page.goto(`${frontendUrl.replace(/\/$/, "")}/home/monitoring`, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + const text = await bodyText(page); + if (!text.includes("Dashboard") && !text.includes("Pipelines") && !text.includes("流水线")) { + throw new Error("Token was written, but authenticated navigation was not visible."); + } + + result.status = "pass"; + result.reason = "Browser profile localStorage token refreshed."; +} catch (error) { + result.status = "fail"; + result.reason = error.message; +} finally { + if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); + if (browser) await browser.close().catch(() => {}); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(result.status === "pass" ? 0 : 1); diff --git a/skills/scripts/e2e/webui-login-state.mjs b/skills/scripts/e2e/webui-login-state.mjs new file mode 100755 index 000000000..fbbb53f33 --- /dev/null +++ b/skills/scripts/e2e/webui-login-state.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node + +import { + bodyText, + createBrowser, + ensureEvidence, + evidencePaths, + exitCode, + gotoFrontend, + isLoginUrl, + loadEnvFiles, + localIsoWithOffset, + safeScreenshot, + verifyBrowserToken, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = "webui-login-state"; +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const startedAt = new Date(); +let browser; +let result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + url: "", + auth: null, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console"], +}; + +try { + browser = await createBrowser(paths); + const { page } = browser; + await gotoFrontend(page); + result.url = page.url(); + + const backendUrl = process.env.LANGBOT_BACKEND_URL || ""; + if (!backendUrl) { + result.status = "env_issue"; + result.reason = "LANGBOT_BACKEND_URL is not configured."; + await safeScreenshot(page, paths.screenshot); + throw new Error(result.reason); + } + + const auth = await verifyBrowserToken(page, backendUrl); + result.auth = auth; + const text = await bodyText(page); + const navigationSignals = [ + "Dashboard", + "Bots", + "Pipelines", + "Knowledge", + "Plugins", + "首页", + "机器人", + "流水线", + "知识库", + "插件", + ]; + const matchedSignal = navigationSignals.find((signal) => text.includes(signal)); + + if (!auth.authenticated) { + result.status = "blocked"; + result.reason = auth.reason || "Browser profile token was not accepted by backend."; + } else if (isLoginUrl(page.url()) || /登录|Login|Sign in/i.test(text)) { + result.status = "fail"; + result.reason = "Backend accepted the token, but the WebUI still showed the login page."; + } else if (!matchedSignal) { + result.status = "fail"; + result.reason = "Opened WebUI, but no known LangBot navigation signal was visible."; + } else { + result.status = "pass"; + result.reason = `Authenticated navigation signal visible: ${matchedSignal}`; + } + + await safeScreenshot(page, paths.screenshot); +} catch (error) { + if (!["env_issue", "blocked", "fail", "pass"].includes(result.status) || !result.reason) { + result.status = /Playwright is not installed|LANGBOT_FRONTEND_URL/.test(error.message) ? "env_issue" : "fail"; + result.reason = error.message; + } +} finally { + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/skills.index.json b/skills/skills.index.json new file mode 100644 index 000000000..640996adc --- /dev/null +++ b/skills/skills.index.json @@ -0,0 +1,1948 @@ +{ + "generated_by": "lbs", + "skills": [ + { + "directory": "langbot-deploy", + "name": "langbot-deploy", + "description": "Deploy and configure a LangBot instance — Docker / Docker Compose, Kubernetes, the config.yaml model, the Box sandbox runtime, the plugin runtime, and the global API key. Use when installing, deploying, upgrading, or configuring LangBot in production or self-hosted environments. Triggers on \"deploy langbot\", \"langbot docker\", \"langbot compose\", \"langbot kubernetes\", \"langbot config.yaml\", \"langbot box runtime\", \"langbot global api key\".", + "references": [], + "cases": [], + "case_summaries": [], + "suites": [], + "suite_summaries": [], + "fixtures": [], + "troubleshooting": [], + "troubleshooting_summaries": [] + }, + { + "directory": "langbot-dev", + "name": "langbot-dev", + "description": "Develop, build, and debug the LangBot core backend and web frontend. Use when working inside the LangBot repository — backend (Python/Quart, src/langbot/pkg), the Vite/React web UI, HTTP API controllers/services, Alembic migrations, or the MCP server. Covers the dev environment (uv, pnpm), repo layout, the API auth model (user token / API key / global key), adding API endpoints, and the rule that API changes must update the MCP server and skills. Triggers on \"langbot backend\", \"langbot dev\", \"langbot api\", \"add langbot endpoint\", \"langbot migration\".", + "references": [], + "cases": [], + "case_summaries": [], + "suites": [], + "suite_summaries": [], + "fixtures": [], + "troubleshooting": [], + "troubleshooting_summaries": [] + }, + { + "directory": "langbot-eba-adapter-dev", + "name": "langbot-eba-adapter-dev", + "description": "Build, refactor, and test LangBot platform adapters for the Event-Based Agents architecture. Use when adding or migrating Telegram, Discord, or other messaging platform adapters to the EBA adapter layout, validating unified event/message conversion, writing live adapter probes, or using standalone plugin runtime plus Computer Use for end-to-end platform testing.", + "references": [], + "cases": [], + "case_summaries": [], + "suites": [], + "suite_summaries": [], + "fixtures": [], + "troubleshooting": [], + "troubleshooting_summaries": [] + }, + { + "directory": "langbot-env-setup", + "name": "langbot-env-setup", + "description": "Prepare a local LangBot development and testing environment for an AI agent. Use when setting up WSL or Linux development, shared local URL variables, proxy variables, backend/frontend startup, Playwright MCP browser access, GitHub OAuth browser login, persisted Chrome profiles, or future Codex computer-use environment paths.", + "references": [ + "references/browser-access-selection.md", + "references/computer-use.md", + "references/oauth-browser-profile.md", + "references/playwright-mcp.md", + "references/proxy.md", + "references/service-startup.md", + "references/wsl-notes.md" + ], + "cases": [], + "case_summaries": [], + "suites": [], + "suite_summaries": [], + "fixtures": [], + "troubleshooting": [], + "troubleshooting_summaries": [] + }, + { + "directory": "langbot-mcp-ops", + "name": "langbot-mcp-ops", + "description": "Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on \"langbot mcp\", \"manage langbot via mcp\", \"langbot /mcp\", \"langbot mcp server\".", + "references": [], + "cases": [], + "case_summaries": [], + "suites": [], + "suite_summaries": [], + "fixtures": [], + "troubleshooting": [], + "troubleshooting_summaries": [] + }, + { + "directory": "langbot-plugin-dev", + "name": "langbot-plugin-dev", + "description": "Develop, debug, and test LangBot plugins. Use when creating new LangBot plugins, fixing plugin bugs, setting up a LangBot test environment, or testing plugins via WebSocket. Covers plugin component architecture (EventListener, Command, Tool), the plugin SDK API (invoke_llm, get_llm_models, send_message, plugin storage), common pitfalls, and automated WebSocket-based testing. Triggers on \"langbot plugin\", \"lbp\", \"GroupChatSummary\", \"plugin debug\", \"langbot test\".", + "references": [ + "references/test-env-setup.md" + ], + "cases": [], + "case_summaries": [], + "suites": [], + "suite_summaries": [], + "fixtures": [], + "troubleshooting": [], + "troubleshooting_summaries": [] + }, + { + "directory": "langbot-skills-maintenance", + "name": "langbot-skills-maintenance", + "description": "Maintain the langbot-skills repository with low duplication. Use when adding, editing, or auditing LangBot skills, references, cases, troubleshooting entries, indexes, or periodic entropy-control checks for this skills repository.", + "references": [ + "references/curation-workflow.md" + ], + "cases": [], + "case_summaries": [], + "suites": [], + "suite_summaries": [], + "fixtures": [], + "troubleshooting": [], + "troubleshooting_summaries": [] + }, + { + "directory": "langbot-space-ops", + "name": "langbot-space-ops", + "description": "Browse and search the LangBot Space marketplaces (plugins, MCP servers, skills) through the Space MCP server. Use when an AI agent needs to discover LangBot extensions on space.langbot.app over MCP. Covers the /mcp endpoint, Personal Access Token (PAT) auth, the tool surface, and client configuration. Triggers on \"langbot space mcp\", \"search langbot plugins\", \"langbot marketplace mcp\", \"space.langbot.app mcp\".", + "references": [], + "cases": [], + "case_summaries": [], + "suites": [], + "suite_summaries": [], + "fixtures": [], + "troubleshooting": [], + "troubleshooting_summaries": [] + }, + { + "directory": "langbot-testing", + "name": "langbot-testing", + "description": "Test LangBot WebUI and core product flows with an automated browser and backend logs. Use when validating the configured LangBot frontend, pipeline Debug Chat, model provider setup and test buttons, bot and knowledge-base UI flows, or troubleshooting failed LangBot end-to-end tests.", + "references": [ + "references/agent-runner-qa-workflow.md", + "references/agent-runner-release-gate.md", + "references/dify-agent-runner.md", + "references/langrag-knowledge-base.md", + "references/local-agent-runner-coverage.md", + "references/local-agent-runner.md", + "references/mcp-stdio-testing.md", + "references/model-provider-testing.md", + "references/performance-reliability-testing.md", + "references/pipeline-debug-chat.md", + "references/plugin-e2e-smoke.md", + "references/sandbox-skill-authoring.md", + "references/troubleshooting.md", + "references/web-ui-testing.md" + ], + "cases": [ + "acp-agent-runner-debug-chat", + "agent-runner-async-db-readiness", + "agent-runner-behavior-matrix", + "agent-runner-fixture-contract", + "agent-runner-ledger-concurrency", + "agent-runner-ledger-contention", + "agent-runner-ledger-invariants", + "agent-runner-ledger-stress", + "agent-runner-live-install", + "agent-runner-qa-debug-chat", + "agent-runner-release-preflight", + "agent-runner-runtime-chaos", + "dify-agent-debug-chat", + "langbot-fake-provider-debug-chat-cross-pipeline-isolation", + "langbot-fake-provider-debug-chat-fault-recovery", + "langbot-fake-provider-debug-chat-load", + "langbot-fake-provider-debug-chat-slow-load", + "langbot-fault-taxonomy-contract", + "langbot-live-backend-latency", + "langbot-live-backend-log-health", + "langbot-live-control-plane-api", + "langbot-overhead-accounting-contract", + "langbot-space-debug-chat-concurrency-smoke", + "langrag-kb-retrieve", + "langrag-parser-golden-e2e", + "langrag-sentinel-kb-discover", + "local-agent-basic-debug-chat", + "local-agent-context-compaction-debug-chat", + "local-agent-effective-prompt-debug-chat", + "local-agent-multimodal-debug-chat", + "local-agent-nonstreaming-debug-chat", + "local-agent-plugin-tool-call-debug-chat", + "local-agent-rag-debug-chat", + "local-agent-rag-multimodal-debug-chat", + "local-agent-steering-debug-chat", + "mcp-stdio-register", + "mcp-stdio-tool-call", + "pipeline-debug-chat", + "pipeline-debug-chat-performance", + "plugin-e2e-smoke", + "provider-deepseek", + "qa-plugin-smoke-live-install", + "sandbox-skill-authoring-e2e", + "sandbox-skill-authoring-edit-existing-e2e", + "webui-login-state" + ], + "case_summaries": [ + { + "id": "acp-agent-runner-debug-chat", + "title": "ACP AgentRunner can answer through Debug Chat using real remote Claude", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p2", + "risk": "high", + "ci_eligible": false, + "tags": [ + "agent-runner", + "acp", + "claude", + "external-runner", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-acp-agent-runner-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL", + "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "console", + "backend_log" + ] + }, + { + "id": "agent-runner-async-db-readiness", + "title": "AgentRunner async DB readiness probe", + "mode": "probe", + "area": "release", + "type": "smoke", + "priority": "p0", + "risk": "high", + "ci_eligible": true, + "tags": [ + "agent-runner", + "probe", + "async-db", + "aiosqlite" + ], + "automation": "skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "filesystem" + ] + }, + { + "id": "agent-runner-behavior-matrix", + "title": "AgentRunner deterministic behavior matrix probe", + "mode": "probe", + "area": "release", + "type": "regression", + "priority": "p0", + "risk": "high", + "ci_eligible": true, + "tags": [ + "agent-runner", + "probe", + "deterministic-runner", + "protocol" + ], + "automation": "skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "filesystem" + ] + }, + { + "id": "agent-runner-fixture-contract", + "title": "QA AgentRunner fixture contract probe", + "mode": "probe", + "area": "release", + "type": "regression", + "priority": "p0", + "risk": "high", + "ci_eligible": true, + "tags": [ + "agent-runner", + "probe", + "fixture", + "deterministic-runner" + ], + "automation": "skills/langbot-testing/probes/agent-runner-fixture-contract.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "filesystem" + ] + }, + { + "id": "agent-runner-ledger-concurrency", + "title": "AgentRunner run ledger concurrency and auth pytest probe", + "mode": "probe", + "area": "release", + "type": "regression", + "priority": "p0", + "risk": "high", + "ci_eligible": true, + "tags": [ + "agent-runner", + "probe", + "pytest", + "run-ledger", + "concurrency" + ], + "automation": "skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "filesystem" + ] + }, + { + "id": "agent-runner-ledger-contention", + "title": "AgentRunner ledger SQLite contention probe", + "mode": "probe", + "area": "release", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": true, + "tags": [ + "agent-runner", + "probe", + "stress", + "ledger", + "concurrency" + ], + "automation": "skills/langbot-testing/probes/agent-runner-ledger-contention.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "filesystem" + ] + }, + { + "id": "agent-runner-ledger-invariants", + "title": "AgentRunner ledger schema and status invariants probe", + "mode": "probe", + "area": "release", + "type": "regression", + "priority": "p0", + "risk": "high", + "ci_eligible": true, + "tags": [ + "agent-runner", + "probe", + "ledger", + "invariant" + ], + "automation": "skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "filesystem" + ] + }, + { + "id": "agent-runner-ledger-stress", + "title": "AgentRunner ledger lightweight stress probe", + "mode": "probe", + "area": "release", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": true, + "tags": [ + "agent-runner", + "probe", + "stress", + "ledger" + ], + "automation": "skills/langbot-testing/probes/agent-runner-ledger-stress.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "filesystem" + ] + }, + { + "id": "agent-runner-live-install", + "title": "QA AgentRunner package installs and registers in LangBot", + "mode": "probe", + "area": "release", + "type": "regression", + "priority": "p0", + "risk": "high", + "ci_eligible": false, + "tags": [ + "agent-runner", + "plugin", + "local-install", + "fixture" + ], + "automation": "scripts/e2e/install-qa-plugin-smoke.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "agent-runner-qa-debug-chat", + "title": "QA AgentRunner returns deterministic output through Debug Chat", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p0", + "risk": "high", + "ci_eligible": false, + "tags": [ + "agent-runner", + "pipeline", + "debug-chat", + "fixture" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "case:agent-runner-live-install", + "node:scripts/e2e/ensure-qa-agent-runner-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL", + "LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "network", + "api_diagnostic" + ] + }, + { + "id": "agent-runner-release-preflight", + "title": "Agent runner release gate preflight validates environment readiness", + "mode": "agent-browser", + "area": "release", + "type": "smoke", + "priority": "p0", + "risk": "high", + "ci_eligible": false, + "tags": [ + "agent-runner", + "release-gate", + "preflight", + "environment" + ], + "automation": "scripts/e2e/agent-runner-release-preflight.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "network", + "api_diagnostic" + ] + }, + { + "id": "agent-runner-runtime-chaos", + "title": "AgentRunner SDK runtime chaos pytest probe", + "mode": "probe", + "area": "release", + "type": "regression", + "priority": "p0", + "risk": "high", + "ci_eligible": true, + "tags": [ + "agent-runner", + "probe", + "pytest", + "runtime", + "sdk" + ], + "automation": "skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "filesystem" + ] + }, + { + "id": "dify-agent-debug-chat", + "title": "Dify AgentRunner returns a response through Pipeline Debug Chat", + "mode": "agent-browser", + "area": "pipeline", + "type": "provider", + "priority": "p2", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "dify", + "agent-runner", + "pipeline" + ], + "automation": "", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "console", + "backend_log" + ] + }, + { + "id": "langbot-fake-provider-debug-chat-cross-pipeline-isolation", + "title": "LangBot Debug Chat fake-provider cross-pipeline isolation probe", + "mode": "probe", + "area": "reliability", + "type": "reliability", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "reliability", + "debug-chat", + "websocket", + "fake-provider", + "isolation", + "concurrency", + "metrics" + ], + "automation": "skills/langbot-testing/probes/langbot-debug-chat-cross-pipeline-isolation.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-fake-provider-cross-pipelines.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_FAKE_PROVIDER_URL", + "LANGBOT_FAKE_PROVIDER_BASE_URL", + "LANGBOT_FAKE_PROVIDER_PID", + "LANGBOT_FAKE_PROVIDER_PIPELINE_A_URL", + "LANGBOT_FAKE_PROVIDER_PIPELINE_A_NAME", + "LANGBOT_FAKE_PROVIDER_PIPELINE_B_URL", + "LANGBOT_FAKE_PROVIDER_PIPELINE_B_NAME" + ], + "evidence_required": [ + "metrics", + "network", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "langbot-fake-provider-debug-chat-fault-recovery", + "title": "LangBot Debug Chat fake-provider fault recovery probe", + "mode": "probe", + "area": "reliability", + "type": "chaos", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "reliability", + "chaos", + "debug-chat", + "websocket", + "fake-provider", + "fault-injection", + "metrics" + ], + "automation": "skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-fake-provider-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_FAKE_PROVIDER_URL", + "LANGBOT_FAKE_PROVIDER_BASE_URL", + "LANGBOT_FAKE_PROVIDER_PID", + "LANGBOT_FAKE_PROVIDER_PROVIDER_UUID", + "LANGBOT_FAKE_PROVIDER_MODEL_UUID", + "LANGBOT_FAKE_PROVIDER_PIPELINE_URL", + "LANGBOT_FAKE_PROVIDER_PIPELINE_NAME" + ], + "evidence_required": [ + "metrics", + "network", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "langbot-fake-provider-debug-chat-load", + "title": "LangBot Debug Chat controlled fake-provider load probe", + "mode": "probe", + "area": "performance", + "type": "performance", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "performance", + "debug-chat", + "websocket", + "fake-provider", + "load", + "metrics" + ], + "automation": "skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-fake-provider-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_FAKE_PROVIDER_URL", + "LANGBOT_FAKE_PROVIDER_BASE_URL", + "LANGBOT_FAKE_PROVIDER_PID", + "LANGBOT_FAKE_PROVIDER_PROVIDER_UUID", + "LANGBOT_FAKE_PROVIDER_MODEL_UUID", + "LANGBOT_FAKE_PROVIDER_PIPELINE_URL", + "LANGBOT_FAKE_PROVIDER_PIPELINE_NAME" + ], + "evidence_required": [ + "metrics", + "network", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "langbot-fake-provider-debug-chat-slow-load", + "title": "LangBot Debug Chat slow fake-provider load probe", + "mode": "probe", + "area": "performance", + "type": "performance", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "performance", + "debug-chat", + "websocket", + "fake-provider", + "slow-provider", + "load", + "metrics" + ], + "automation": "skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-fake-provider-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_FAKE_PROVIDER_URL", + "LANGBOT_FAKE_PROVIDER_BASE_URL", + "LANGBOT_FAKE_PROVIDER_PID", + "LANGBOT_FAKE_PROVIDER_PROVIDER_UUID", + "LANGBOT_FAKE_PROVIDER_MODEL_UUID", + "LANGBOT_FAKE_PROVIDER_PIPELINE_URL", + "LANGBOT_FAKE_PROVIDER_PIPELINE_NAME" + ], + "evidence_required": [ + "metrics", + "network", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "langbot-fault-taxonomy-contract", + "title": "LangBot fault taxonomy and cleanup contract", + "mode": "probe", + "area": "reliability", + "type": "chaos", + "priority": "p1", + "risk": "medium", + "ci_eligible": true, + "tags": [ + "reliability", + "chaos", + "contract", + "synthetic" + ], + "automation": "skills/langbot-testing/probes/langbot-fault-taxonomy-contract.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "metrics", + "filesystem" + ] + }, + { + "id": "langbot-live-backend-latency", + "title": "LangBot live backend basic latency probe", + "mode": "probe", + "area": "performance", + "type": "performance", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "performance", + "live-backend", + "latency", + "metrics" + ], + "automation": "skills/langbot-testing/probes/langbot-live-backend-latency.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "metrics", + "network", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "langbot-live-backend-log-health", + "title": "LangBot live backend log health probe", + "mode": "probe", + "area": "reliability", + "type": "reliability", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "reliability", + "live-backend", + "backend-log", + "metrics" + ], + "automation": "skills/langbot-testing/probes/langbot-live-backend-log-health.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "metrics", + "backend_log", + "filesystem" + ] + }, + { + "id": "langbot-live-control-plane-api", + "title": "LangBot live control-plane API probe", + "mode": "probe", + "area": "performance", + "type": "performance", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "performance", + "reliability", + "live-backend", + "control-plane", + "metrics" + ], + "automation": "skills/langbot-testing/probes/langbot-live-control-plane-api.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "metrics", + "network", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "langbot-overhead-accounting-contract", + "title": "LangBot overhead accounting metrics contract", + "mode": "probe", + "area": "performance", + "type": "performance", + "priority": "p1", + "risk": "medium", + "ci_eligible": true, + "tags": [ + "performance", + "metrics", + "contract", + "synthetic" + ], + "automation": "skills/langbot-testing/probes/langbot-overhead-accounting-contract.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "metrics", + "resource_log", + "filesystem" + ] + }, + { + "id": "langbot-space-debug-chat-concurrency-smoke", + "title": "LangBot Debug Chat real Space-provider concurrency smoke", + "mode": "probe", + "area": "performance", + "type": "performance", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "performance", + "debug-chat", + "websocket", + "space", + "live-provider", + "smoke", + "metrics" + ], + "automation": "skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_PIPELINE_URL", + "LANGBOT_PIPELINE_NAME", + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + "LANGBOT_LOCAL_AGENT_MODEL_UUID", + "LANGBOT_E2E_MODEL_UUID" + ], + "evidence_required": [ + "metrics", + "network", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "langrag-kb-retrieve", + "title": "LangRAG knowledge base ingests and retrieves a sentinel document", + "mode": "agent-browser", + "area": "knowledge", + "type": "feature", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "langrag", + "knowledge", + "rag" + ], + "automation": "scripts/e2e/langrag-kb-retrieve.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log" + ] + }, + { + "id": "langrag-parser-golden-e2e", + "title": "LangRAG and GeneralParsers retrieve a structured golden document", + "mode": "agent-browser", + "area": "knowledge", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "golden", + "e2e", + "langrag", + "parser", + "knowledge" + ], + "automation": "", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log" + ] + }, + { + "id": "langrag-sentinel-kb-discover", + "title": "Existing LangRAG sentinel knowledge base is discoverable", + "mode": "probe", + "area": "knowledge", + "type": "regression", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "langrag", + "knowledge", + "rag", + "fixture" + ], + "automation": "scripts/e2e/ensure-langrag-sentinel-kb.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "api_diagnostic" + ] + }, + { + "id": "local-agent-basic-debug-chat", + "title": "Local Agent Debug Chat returns a deterministic streaming response", + "mode": "agent-browser", + "area": "pipeline", + "type": "smoke", + "priority": "p0", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "local-agent", + "pipeline", + "streaming" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log" + ] + }, + { + "id": "local-agent-context-compaction-debug-chat", + "title": "Local Agent compacts long Debug Chat history and preserves older facts", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "pipeline", + "context", + "compaction" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log", + "api_diagnostic" + ] + }, + { + "id": "local-agent-effective-prompt-debug-chat", + "title": "Local Agent consumes host effective prompt after PromptPreProcessing", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "prompt", + "plugin", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "case:qa-plugin-smoke-live-install" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "console", + "backend_log" + ] + }, + { + "id": "local-agent-multimodal-debug-chat", + "title": "Local Agent Debug Chat preserves uploaded image input", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p2", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "local-agent", + "multimodal", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "network", + "backend_log" + ] + }, + { + "id": "local-agent-nonstreaming-debug-chat", + "title": "Local Agent Debug Chat returns a deterministic non-streaming response", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "local-agent", + "pipeline", + "non-streaming" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "console", + "backend_log" + ] + }, + { + "id": "local-agent-plugin-tool-call-debug-chat", + "title": "Local Agent can call a plugin-provided tool", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "plugin", + "tools", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "case:qa-plugin-smoke-live-install" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "console", + "backend_log", + "api_diagnostic" + ] + }, + { + "id": "local-agent-rag-debug-chat", + "title": "Local Agent Debug Chat answers from a LangRAG knowledge base", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "langrag", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + "LANGBOT_LOCAL_AGENT_RAG_KB_UUID" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log", + "api_diagnostic" + ] + }, + { + "id": "local-agent-rag-multimodal-debug-chat", + "title": "Local Agent preserves image input while using RAG context", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p2", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "rag", + "multimodal", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + "LANGBOT_LOCAL_AGENT_RAG_KB_UUID" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log" + ] + }, + { + "id": "local-agent-steering-debug-chat", + "title": "Local Agent injects a follow-up into an active run", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "pipeline", + "steering", + "tools" + ], + "automation": "scripts/e2e/local-agent-steering-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "case:qa-plugin-smoke-live-install" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log", + "api_diagnostic" + ] + }, + { + "id": "mcp-stdio-register", + "title": "MCP stdio fixture is registered and exposes qa_mcp_echo", + "mode": "agent-browser", + "area": "mcp", + "type": "smoke", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "mcp", + "tools", + "fixture", + "preflight" + ], + "automation": "scripts/e2e/mcp-stdio-register.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "screenshot", + "console", + "network", + "api_diagnostic" + ] + }, + { + "id": "mcp-stdio-tool-call", + "title": "MCP stdio server exposes a tool that Local Agent can call", + "mode": "agent-browser", + "area": "mcp", + "type": "feature", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "mcp", + "tools", + "local-agent" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "case:mcp-stdio-register" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "console", + "backend_log", + "api_diagnostic" + ] + }, + { + "id": "pipeline-debug-chat", + "title": "Pipeline Debug Chat returns a bot response", + "mode": "agent-browser", + "area": "pipeline", + "type": "smoke", + "priority": "p0", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "smoke", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log" + ] + }, + { + "id": "pipeline-debug-chat-performance", + "title": "Pipeline Debug Chat user-path performance probe", + "mode": "agent-browser", + "area": "pipeline", + "type": "performance", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "performance", + "pipeline", + "debug-chat", + "user-path", + "metrics" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_PIPELINE_URL", + "LANGBOT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "network", + "metrics" + ] + }, + { + "id": "plugin-e2e-smoke", + "title": "Plugin system installs a local plugin and exposes tool/page APIs", + "mode": "agent-browser", + "area": "plugin", + "type": "smoke", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "plugin", + "runtime", + "local-install", + "e2e" + ], + "automation": "", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "console", + "backend_log", + "api_diagnostic" + ] + }, + { + "id": "provider-deepseek", + "title": "DeepSeek provider can be configured and used", + "mode": "agent-browser", + "area": "provider", + "type": "provider", + "priority": "p2", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "provider", + "model" + ], + "automation": "", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "console", + "backend_log" + ] + }, + { + "id": "qa-plugin-smoke-live-install", + "title": "QA plugin smoke package installs and exposes tools", + "mode": "probe", + "area": "plugin", + "type": "regression", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "plugin", + "local-install", + "fixture" + ], + "automation": "scripts/e2e/install-qa-plugin-smoke.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "sandbox-skill-authoring-e2e", + "title": "Local Agent creates, registers, activates, and uses a sandbox skill", + "mode": "agent-browser", + "area": "sandbox", + "type": "regression", + "priority": "p2", + "risk": "high", + "ci_eligible": false, + "tags": [ + "sandbox", + "skills", + "local-agent", + "tools", + "e2b", + "nsjail" + ], + "automation": "", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "backend_log", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "sandbox-skill-authoring-edit-existing-e2e", + "title": "Local Agent modifies an activated sandbox skill package", + "mode": "agent-browser", + "area": "sandbox", + "type": "regression", + "priority": "p2", + "risk": "high", + "ci_eligible": false, + "tags": [ + "sandbox", + "skills", + "local-agent", + "tools", + "edit" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "backend_log", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "webui-login-state", + "title": "Configured frontend opens with authenticated LangBot WebUI state", + "mode": "agent-browser", + "area": "auth", + "type": "smoke", + "priority": "p0", + "risk": "low", + "ci_eligible": false, + "tags": [ + "smoke", + "auth" + ], + "automation": "scripts/e2e/webui-login-state.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console" + ] + } + ], + "suites": [ + "agent-runner-release-gate", + "core-smoke", + "langbot-debug-chat-isolation-gate", + "langbot-debug-chat-load-gate", + "langbot-live-backend-gate", + "langbot-performance-contract-gate", + "langbot-performance-reliability-gate", + "langbot-user-path-performance-gate", + "local-agent-gate" + ], + "suite_summaries": [ + { + "id": "agent-runner-release-gate", + "title": "Agent runner externalization release gate", + "description": "Release gate for runner externalization: pytest probes, environment preflight, fixture registration, local-agent capability paths, and ACP external harness execution.", + "type": "release_gate", + "priority": "p0", + "tags": [ + "agent-runner", + "release-gate", + "local-agent", + "external-runner" + ], + "cases": [ + "agent-runner-fixture-contract", + "agent-runner-behavior-matrix", + "agent-runner-ledger-invariants", + "agent-runner-async-db-readiness", + "agent-runner-ledger-stress", + "agent-runner-ledger-contention", + "agent-runner-ledger-concurrency", + "agent-runner-runtime-chaos", + "agent-runner-live-install", + "agent-runner-qa-debug-chat", + "agent-runner-release-preflight", + "webui-login-state", + "pipeline-debug-chat", + "qa-plugin-smoke-live-install", + "plugin-e2e-smoke", + "langrag-kb-retrieve", + "local-agent-basic-debug-chat", + "local-agent-effective-prompt-debug-chat", + "local-agent-context-compaction-debug-chat", + "local-agent-rag-debug-chat", + "local-agent-plugin-tool-call-debug-chat", + "mcp-stdio-register", + "mcp-stdio-tool-call", + "local-agent-nonstreaming-debug-chat", + "local-agent-multimodal-debug-chat", + "local-agent-rag-multimodal-debug-chat", + "acp-agent-runner-debug-chat" + ] + }, + { + "id": "core-smoke", + "title": "Core browser smoke suite", + "description": "Fast browser-first checks for login state, Pipeline Debug Chat, and the basic local-agent runner path.", + "type": "smoke", + "priority": "p0", + "tags": [ + "smoke", + "browser", + "p0" + ], + "cases": [ + "webui-login-state", + "pipeline-debug-chat", + "local-agent-basic-debug-chat" + ] + }, + { + "id": "langbot-debug-chat-isolation-gate", + "title": "LangBot Debug Chat isolation gate", + "description": "Manual/non-required cross-pipeline Debug Chat isolation gate. Current releases may fail this gate because of product bug #2286; use it as regression evidence after the routing fix lands.", + "type": "reliability", + "priority": "p1", + "tags": [ + "reliability", + "debug-chat", + "websocket", + "isolation", + "concurrency" + ], + "cases": [ + "langbot-fake-provider-debug-chat-cross-pipeline-isolation" + ] + }, + { + "id": "langbot-debug-chat-load-gate", + "title": "LangBot Debug Chat load gate", + "description": "Manual/non-required message-path load checks for Pipeline Debug Chat: controlled fake-provider baseline, slow-provider and fault-recovery profiles, plus optional real Space-provider smoke. Cross-pipeline isolation is split into langbot-debug-chat-isolation-gate because current releases may fail it due to product bug #2286.", + "type": "performance", + "priority": "p1", + "tags": [ + "performance", + "debug-chat", + "websocket", + "load" + ], + "cases": [ + "langbot-fake-provider-debug-chat-load", + "langbot-fake-provider-debug-chat-slow-load", + "langbot-fake-provider-debug-chat-fault-recovery", + "langbot-space-debug-chat-concurrency-smoke" + ] + }, + { + "id": "langbot-live-backend-gate", + "title": "LangBot live backend reliability gate", + "description": "Live backend control-plane responsiveness and runtime log health checks for a locally running LangBot instance.", + "type": "reliability", + "priority": "p1", + "tags": [ + "performance", + "reliability", + "live-backend", + "metrics" + ], + "cases": [ + "langbot-live-backend-latency", + "langbot-live-control-plane-api", + "langbot-live-backend-log-health" + ] + }, + { + "id": "langbot-performance-contract-gate", + "title": "LangBot performance contract gate", + "description": "Fast synthetic contract checks for performance metric accounting and non-destructive reliability fault taxonomy.", + "type": "contract", + "priority": "p1", + "tags": [ + "performance", + "reliability", + "contract", + "metrics" + ], + "cases": [ + "langbot-overhead-accounting-contract", + "langbot-fault-taxonomy-contract" + ] + }, + { + "id": "langbot-performance-reliability-gate", + "title": "LangBot performance and reliability starter gate", + "description": "Starter gate for LangBot performance accounting, live backend control-plane latency, and non-destructive fault taxonomy checks.", + "type": "reliability", + "priority": "p1", + "tags": [ + "performance", + "reliability", + "metrics", + "chaos" + ], + "cases": [ + "langbot-overhead-accounting-contract", + "langbot-fault-taxonomy-contract", + "langbot-live-backend-latency", + "langbot-live-control-plane-api", + "langbot-live-backend-log-health" + ] + }, + { + "id": "langbot-user-path-performance-gate", + "title": "LangBot user-path performance gate", + "description": "Browser-visible performance checks for user-facing LangBot paths such as Pipeline Debug Chat.", + "type": "performance", + "priority": "p1", + "tags": [ + "performance", + "browser", + "debug-chat", + "user-path" + ], + "cases": [ + "pipeline-debug-chat-performance" + ] + }, + { + "id": "local-agent-gate", + "title": "Local Agent runner regression gate", + "description": "High-signal local-agent runner checks covering prompt bridge, RAG, context compaction, plugin tools, MCP tools, non-streaming, and multimodal paths.", + "type": "regression", + "priority": "p1", + "tags": [ + "local-agent", + "agent-runner", + "regression" + ], + "cases": [ + "local-agent-basic-debug-chat", + "qa-plugin-smoke-live-install", + "local-agent-effective-prompt-debug-chat", + "local-agent-context-compaction-debug-chat", + "local-agent-rag-debug-chat", + "local-agent-plugin-tool-call-debug-chat", + "local-agent-steering-debug-chat", + "mcp-stdio-tool-call", + "local-agent-nonstreaming-debug-chat", + "local-agent-multimodal-debug-chat", + "local-agent-rag-multimodal-debug-chat" + ] + } + ], + "fixtures": [ + { + "id": "qa-agent-runner-behaviors", + "title": "Deterministic AgentRunner behavior matrix", + "kind": "json", + "path": "fixtures/agent-runner/qa-runner-behaviors.json", + "related_cases": [ + "agent-runner-behavior-matrix", + "agent-runner-ledger-invariants", + "agent-runner-runtime-chaos" + ] + }, + { + "id": "qa-agent-runner-source", + "title": "QA deterministic AgentRunner fixture source", + "kind": "plugin_source", + "path": "fixtures/plugins/qa-agent-runner/manifest.yaml", + "related_cases": [ + "agent-runner-fixture-contract", + "agent-runner-behavior-matrix", + "agent-runner-live-install", + "agent-runner-qa-debug-chat" + ] + }, + { + "id": "qa-agent-runner-package", + "title": "QA deterministic AgentRunner prebuilt package", + "kind": "plugin_package", + "path": "fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg", + "related_cases": [ + "agent-runner-fixture-contract", + "agent-runner-live-install", + "agent-runner-qa-debug-chat" + ] + }, + { + "id": "mcp-stdio-echo-server", + "title": "MCP stdio qa_mcp_echo server", + "kind": "python", + "path": "fixtures/mcp/qa_mcp_echo_server.py", + "related_cases": [ + "mcp-stdio-tool-call" + ] + }, + { + "id": "rag-sentinel-doc", + "title": "LangRAG sentinel text document", + "kind": "text", + "path": "fixtures/rag/sentinel-doc.txt", + "related_cases": [ + "langrag-kb-retrieve", + "local-agent-rag-debug-chat" + ] + }, + { + "id": "rag-parser-golden-html", + "title": "LangRAG parser golden HTML document", + "kind": "html", + "path": "fixtures/rag/parser-golden.html", + "related_cases": [ + "langrag-parser-golden-e2e" + ] + }, + { + "id": "multimodal-red-square", + "title": "64x64 red-square image fixture", + "kind": "base64_png", + "path": "fixtures/multimodal/red-square.png.base64", + "related_cases": [ + "local-agent-multimodal-debug-chat", + "local-agent-rag-multimodal-debug-chat" + ] + }, + { + "id": "qa-plugin-smoke-source", + "title": "QA plugin smoke fixture source", + "kind": "plugin_source", + "path": "fixtures/plugins/qa-plugin-smoke/manifest.yaml", + "related_cases": [ + "qa-plugin-smoke-live-install", + "plugin-e2e-smoke", + "local-agent-effective-prompt-debug-chat", + "local-agent-plugin-tool-call-debug-chat", + "local-agent-steering-debug-chat" + ] + }, + { + "id": "qa-plugin-smoke-package", + "title": "QA plugin smoke prebuilt package", + "kind": "plugin_package", + "path": "fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg", + "related_cases": [ + "qa-plugin-smoke-live-install", + "plugin-e2e-smoke" + ] + } + ], + "troubleshooting": [ + "agent-runner-actor-context-fields", + "aiosqlite-connect-hangs", + "ambiguous-runner-default-label", + "backend-not-listening", + "box-session-conflict-logical-metadata", + "debug-chat-history-contaminates-automation", + "dynamic-form-missing-config-id", + "e2b-extra-mount-sync-missing", + "local-agent-model-route-unavailable", + "marketplace-network-flaky", + "mcp-stdio-args-not-applied", + "nsjail-cli-compatibility", + "pipeline-form-controlled-warning", + "plugin-dependency-install-offline", + "plugin-runtime-timeout", + "provider-image-parse-error", + "proxy-env-mismatch", + "sandbox-native-tools-unavailable", + "socks-proxy-without-socksio", + "survey-widget-blocks-debug-chat", + "telemetry-proxy-noise", + "tool-name-collision-between-mcp-and-plugin", + "uv-run-resyncs-local-sdk" + ], + "troubleshooting_summaries": [ + { + "id": "agent-runner-actor-context-fields", + "title": "AgentRunner reads old actor.type and actor.id fields", + "category": "product", + "related_cases": [ + "dify-agent-debug-chat", + "pipeline-debug-chat" + ] + }, + { + "id": "aiosqlite-connect-hangs", + "title": "aiosqlite connect hangs before ledger pytest starts", + "category": "env_issue", + "related_cases": [ + "agent-runner-ledger-concurrency", + "agent-runner-ledger-invariants" + ] + }, + { + "id": "ambiguous-runner-default-label", + "title": "AgentRunner selector shows multiple Default or 默认 options", + "category": "product", + "related_cases": [ + "dify-agent-debug-chat", + "local-agent-rag-debug-chat" + ] + }, + { + "id": "backend-not-listening", + "title": "LangBot backend URL has no listening service", + "category": "product", + "related_cases": [ + "pipeline-debug-chat", + "webui-login-state", + "local-agent-basic-debug-chat" + ] + }, + { + "id": "box-session-conflict-logical-metadata", + "title": "BoxSessionConflictError after a successful first exec", + "category": "product", + "related_cases": [ + "sandbox-skill-authoring-e2e" + ] + }, + { + "id": "debug-chat-history-contaminates-automation", + "title": "Old Debug Chat messages contaminate automation assertions", + "category": "product", + "related_cases": [ + "pipeline-debug-chat", + "local-agent-plugin-tool-call-debug-chat", + "mcp-stdio-tool-call" + ] + }, + { + "id": "dynamic-form-missing-config-id", + "title": "Dynamic form fields have no stable key", + "category": "product", + "related_cases": [ + "langrag-kb-retrieve", + "local-agent-rag-debug-chat" + ] + }, + { + "id": "e2b-extra-mount-sync-missing", + "title": "Activated skill files are missing or not written back on E2B", + "category": "product", + "related_cases": [ + "sandbox-skill-authoring-e2e" + ] + }, + { + "id": "local-agent-model-route-unavailable", + "title": "Local Agent model route is unavailable for the requested run shape", + "category": "env_issue", + "related_cases": [ + "local-agent-basic-debug-chat", + "local-agent-plugin-tool-call-debug-chat", + "mcp-stdio-tool-call", + "local-agent-multimodal-debug-chat", + "local-agent-nonstreaming-debug-chat" + ] + }, + { + "id": "marketplace-network-flaky", + "title": "Marketplace requests are flaky but plugin data may still load", + "category": "product", + "related_cases": [ + "langrag-kb-retrieve" + ] + }, + { + "id": "mcp-stdio-args-not-applied", + "title": "MCP Stdio test runs only the command without arguments", + "category": "product", + "related_cases": [ + "mcp-stdio-tool-call" + ] + }, + { + "id": "nsjail-cli-compatibility", + "title": "Installed nsjail CLI rejects generated sandbox arguments", + "category": "product", + "related_cases": [ + "sandbox-skill-authoring-e2e" + ] + }, + { + "id": "pipeline-form-controlled-warning", + "title": "Pipeline form input switches from uncontrolled to controlled", + "category": "product", + "related_cases": [ + "pipeline-debug-chat", + "local-agent-rag-debug-chat" + ] + }, + { + "id": "plugin-dependency-install-offline", + "title": "Local plugin dependency install fails in an offline or proxy-limited runtime", + "category": "product", + "related_cases": [ + "langrag-parser-golden-e2e" + ] + }, + { + "id": "plugin-runtime-timeout", + "title": "Plugin runtime actions time out", + "category": "product", + "related_cases": [ + "pipeline-debug-chat", + "provider-deepseek", + "langrag-parser-golden-e2e" + ] + }, + { + "id": "provider-image-parse-error", + "title": "Model provider rejects the uploaded image before runner behavior is exercised", + "category": "product", + "related_cases": [ + "local-agent-multimodal-debug-chat" + ] + }, + { + "id": "proxy-env-mismatch", + "title": "Uppercase and lowercase proxy variables differ", + "category": "product", + "related_cases": [ + "pipeline-debug-chat", + "provider-deepseek", + "webui-login-state" + ] + }, + { + "id": "sandbox-native-tools-unavailable", + "title": "Native sandbox tools are unavailable even though a backend is configured", + "category": "product", + "related_cases": [ + "sandbox-skill-authoring-e2e" + ] + }, + { + "id": "socks-proxy-without-socksio", + "title": "Python HTTP clients fail when ALL_PROXY uses SOCKS without socksio", + "category": "product", + "related_cases": [ + "sandbox-skill-authoring-e2e", + "provider-deepseek" + ] + }, + { + "id": "survey-widget-blocks-debug-chat", + "title": "Survey widget blocks Debug Chat controls", + "category": "product", + "related_cases": [ + "pipeline-debug-chat", + "local-agent-rag-debug-chat", + "mcp-stdio-tool-call" + ] + }, + { + "id": "telemetry-proxy-noise", + "title": "Telemetry posting fails through the proxy while the target flow succeeds", + "category": "env_issue", + "related_cases": [ + "langbot-space-debug-chat-concurrency-smoke" + ] + }, + { + "id": "tool-name-collision-between-mcp-and-plugin", + "title": "MCP and plugin expose the same tool name", + "category": "product", + "related_cases": [ + "mcp-stdio-tool-call", + "local-agent-plugin-tool-call-debug-chat" + ] + }, + { + "id": "uv-run-resyncs-local-sdk", + "title": "uv run resyncs the locked SDK instead of the local editable SDK", + "category": "product", + "related_cases": [ + "plugin-e2e-smoke" + ] + } + ] + } + ] +} diff --git a/skills/skills/.env b/skills/skills/.env new file mode 100644 index 000000000..4e5aae69f --- /dev/null +++ b/skills/skills/.env @@ -0,0 +1,46 @@ +# Shared defaults for LangBot skills. +# Agents should read this file first, then load machine-local overrides from +# skills/.env.local. Do not put workstation-specific absolute paths or secrets +# in this committed file. + +# The UI URL that testing skills should open. +# Default to the standalone Vite frontend. Set this to the backend WebUI URL +# instead if your LangBot checkout serves the frontend from the backend. +LANGBOT_FRONTEND_URL=http://127.0.0.1:3000 + +# LangBot API/backend URL. +LANGBOT_BACKEND_URL=http://127.0.0.1:5300 + +# Common standalone frontend dev URL. This is a candidate, not the default. +LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:3000 + +# Local repository paths. Copy skills/.env.example to skills/.env.local and set +# these for your checkout. +LANGBOT_REPO= +LANGBOT_WEB_REPO= +LANGBOT_RAG_PLUGIN_REPO= +LANGBOT_PARSER_PLUGIN_REPO= + +# Browser profile and Playwright/Chromium paths. +LANGBOT_BROWSER_PROFILE= +LANGBOT_CHROMIUM_EXECUTABLE= + +# Optional local proxy defaults. Do not store secrets here. +LANGBOT_PROXY_HTTP= +LANGBOT_PROXY_SOCKS= +LANGBOT_NO_PROXY=localhost,127.0.0.1,::1 + +# Optional case-specific pipeline targets. Put machine-local values in +# skills/.env.local so runner-specific cases do not accidentally reuse the +# generic LANGBOT_PIPELINE_URL. +# LANGBOT_PIPELINE_URL=http://127.0.0.1:3000/home/pipelines?id= +# LANGBOT_PIPELINE_NAME=Generic QA Pipeline +# LANGBOT_LOCAL_AGENT_PIPELINE_URL=http://127.0.0.1:3000/home/pipelines?id= +# LANGBOT_LOCAL_AGENT_PIPELINE_NAME=Local Agent QA Pipeline +# LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL=http://127.0.0.1:3000/home/pipelines?id= +# LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME=ACP AgentRunner QA Pipeline +# LANGBOT_ACP_AGENT_RUNNER_SSH_TARGET=yhh@101.34.71.12 +# LANGBOT_ACP_AGENT_RUNNER_SSH_PORT=22 +# LANGBOT_ACP_AGENT_RUNNER_SSH_IDENTITY_FILE= +# LANGBOT_ACP_AGENT_RUNNER_SSH_EXTRA_OPTIONS= +# LANGBOT_ACP_AGENT_RUNNER_REMOTE_WORKSPACE=/home/yhh/langbot-e2e/acp-workspace diff --git a/skills/skills/.env.example b/skills/skills/.env.example new file mode 100644 index 000000000..888c5721d --- /dev/null +++ b/skills/skills/.env.example @@ -0,0 +1,53 @@ +# Copy this file to skills/.env.local and adjust it for your machine. +# Do not put API keys, OAuth tokens, browser localStorage tokens, or provider +# credentials in committed files. + +# LangBot WebUI and backend endpoints. +LANGBOT_FRONTEND_URL=http://127.0.0.1:3000 +LANGBOT_BACKEND_URL=http://127.0.0.1:5300 +LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:3000 + +# Local repository paths. +LANGBOT_REPO=/path/to/LangBot +LANGBOT_WEB_REPO=/path/to/LangBot/web +LANGBOT_RAG_PLUGIN_REPO=/path/to/langbot-rag +LANGBOT_PARSER_PLUGIN_REPO=/path/to/langbot-parser + +# Browser profile and Playwright/Chromium paths. +LANGBOT_BROWSER_PROFILE=/path/to/langbot-playwright-profile +LANGBOT_CHROMIUM_EXECUTABLE=/path/to/ms-playwright/chromium/chrome + +# Optional local proxy defaults. Leave blank if not needed. +LANGBOT_PROXY_HTTP= +LANGBOT_PROXY_SOCKS= +LANGBOT_NO_PROXY=localhost,127.0.0.1,::1 + +# Optional generic pipeline target for generic Debug Chat smoke tests. +LANGBOT_PIPELINE_URL= +LANGBOT_PIPELINE_NAME= + +# Optional fake OpenAI-compatible provider controls for Debug Chat load tests. +# Leave URL empty to let setup automation start a local provider and write the +# selected URL to skills/.env.local. +LANGBOT_FAKE_PROVIDER_URL= +LANGBOT_FAKE_PROVIDER_HOST=127.0.0.1 +LANGBOT_FAKE_PROVIDER_PORT= +LANGBOT_FAKE_PROVIDER_MODEL_NAME=gpt-4o-mini +LANGBOT_FAKE_PROVIDER_RESPONSE_TEXT=OK +LANGBOT_FAKE_PROVIDER_FIRST_TOKEN_DELAY_MS=25 +LANGBOT_FAKE_PROVIDER_CHUNK_DELAY_MS=10 +LANGBOT_FAKE_PROVIDER_CHUNK_COUNT=0 +LANGBOT_FAKE_PROVIDER_FAIL_FIRST_N=0 +LANGBOT_FAKE_PROVIDER_FAIL_EVERY_N=0 +LANGBOT_FAKE_PROVIDER_FAULT_STATUS=500 +LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK=false +LANGBOT_FAKE_PROVIDER_DYNAMIC_RESPONSE=true + +# Optional case-specific runner targets. Prefer these for runner-specific cases +# so the automation cannot silently test the wrong runner. +LANGBOT_LOCAL_AGENT_PIPELINE_URL= +LANGBOT_LOCAL_AGENT_PIPELINE_NAME= +LANGBOT_CODEX_AGENT_PIPELINE_URL= +LANGBOT_CODEX_AGENT_PIPELINE_NAME= +LANGBOT_CLAUDE_CODE_AGENT_PIPELINE_URL= +LANGBOT_CLAUDE_CODE_AGENT_PIPELINE_NAME= diff --git a/skills/skills/langbot-deploy/SKILL.md b/skills/skills/langbot-deploy/SKILL.md new file mode 100644 index 000000000..3a314fbfa --- /dev/null +++ b/skills/skills/langbot-deploy/SKILL.md @@ -0,0 +1,84 @@ +--- +name: langbot-deploy +description: Deploy and configure a LangBot instance — Docker / Docker Compose, Kubernetes, the config.yaml model, the Box sandbox runtime, the plugin runtime, and the global API key. Use when installing, deploying, upgrading, or configuring LangBot in production or self-hosted environments. Triggers on "deploy langbot", "langbot docker", "langbot compose", "langbot kubernetes", "langbot config.yaml", "langbot box runtime", "langbot global api key". +--- + +# LangBot Deployment & Configuration + +Covers running LangBot in production. For development see `langbot-dev`. + +## Docker Compose (recommended) + +```bash +git clone https://github.com/langbot-app/LangBot +cd LangBot/docker + +# Full stack (sandbox/Box + stdio MCP hosting + skill add/edit enabled) +docker compose --profile all up + +# Basic (no Box runtime) +docker compose up +``` + +The `all` / `box` profile starts three services: + +- `langbot` — main app, serves API + UI on `:5300`. +- `langbot_plugin_runtime` — plugin runtime (control `:5400`, debug `:5401`). +- `langbot_box` — Box sandbox runtime (`:5410`). Uses the host Docker socket to + spawn sandbox containers, so the **Box root host path and in-container path + must be identical** (`BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}`). + +With Box off, the dashboard/skills list stays visible (read-only) but sandbox +tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false` +(or `BOX__ENABLED=false`) to match. + +## Kubernetes + +See `docker/kubernetes.yaml` and the deployment guide at +https://docs.langbot.app. `docker/deploy-k8s-test.sh` is a test helper. + +## config.yaml (generated at `data/config.yaml` on first run) + +Top-level sections: `api`, `system`, `command`, `concurrency`, `proxy`, +`database`, `vdb`, `storage`, `plugin`, `monitoring`, `box`, `space`. + +Key settings: + +| Key | Meaning | +| --- | --- | +| `api.port` | HTTP API + UI port (default 5300) | +| `api.global_api_key` | **Global API key** for the HTTP API + MCP server. Non-empty = accepted with no login/DB record; no `lbk_` prefix required. Empty = disabled. Plaintext — trusted/internal only, serve over HTTPS. | +| `plugin.runtime_ws_url` | Standalone plugin runtime WS URL (e.g. `ws://langbot_plugin_runtime:5400/control/ws`) | +| `box.enabled` | Master switch for the Box sandbox runtime | +| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b`; env override `BOX__BACKEND` | +| `box.runtime.endpoint` | External Box runtime URL (e.g. `ws://127.0.0.1:5410`); empty = local auto-managed | + +Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`). + +## Runtimes & flags + +- LangBot started directly spawns the plugin runtime over **stdio**. +- In containers it connects to a standalone runtime over **WebSocket**; start + with `--standalone-runtime`. +- Box has a parallel `--standalone-box` flag; the Docker box host is + `langbot_box:5410`. + +## Global API key — enabling for agents/automation + +```yaml +# data/config.yaml +api: + port: 5300 + global_api_key: 'a-strong-secret' # empty disables it +``` + +This key authenticates both the HTTP API and the MCP server (`/mcp`) without a +login session. See `langbot-mcp-ops` for using it, and `docs/API_KEY_AUTH.md`. + +## Pitfalls + +- "No supported sandbox backend (Docker / nsjail / E2B)" with Docker running + usually means the user isn't in the `docker` group → + `sudo usermod -aG docker ` and restart in a new shell. +- Box root host/container path mismatch breaks sandbox container creation. +- Don't commit a non-empty `api.global_api_key` to version control. diff --git a/skills/skills/langbot-dev/SKILL.md b/skills/skills/langbot-dev/SKILL.md new file mode 100644 index 000000000..01ee06fd1 --- /dev/null +++ b/skills/skills/langbot-dev/SKILL.md @@ -0,0 +1,116 @@ +--- +name: langbot-dev +description: Develop, build, and debug the LangBot core backend and web frontend. Use when working inside the LangBot repository — backend (Python/Quart, src/langbot/pkg), the Vite/React web UI, HTTP API controllers/services, Alembic migrations, or the MCP server. Covers the dev environment (uv, pnpm), repo layout, the API auth model (user token / API key / global key), adding API endpoints, and the rule that API changes must update the MCP server and skills. Triggers on "langbot backend", "langbot dev", "langbot api", "add langbot endpoint", "langbot migration". +--- + +# LangBot Core Development + +This skill covers developing the LangBot core (the main repo), distinct from +plugin development (see `langbot-plugin-dev`) and deployment (`langbot-deploy`). + +## Stack + +- **Backend**: Python `>=3.11,<4.0`, deps via `uv`. Framework: **Quart** (async + Flask). Serves the HTTP API + pre-built web UI on `http://127.0.0.1:5300`. +- **Frontend** (`web/`): **Vite + React Router 7 + shadcn/ui + Tailwind**, + managed by `pnpm`. Dev server on `:3000`. (NOT Next.js — `dev` script is `vite`.) + +## Dev environment + +```bash +# Backend +pip install uv +uv sync --dev +uv run main.py # API + UI on http://127.0.0.1:5300 + +# Frontend (separate terminal) +cd web +cp .env.example .env +pnpm install +pnpm dev # http://127.0.0.1:3000 (reads VITE_API_BASE_URL) + +# Lint/format hooks (CI runs the same checks) +uv run pre-commit install +``` + +First run generates `data/config.yaml`; DB defaults to SQLite (PostgreSQL +supported). Migrations run automatically on startup. + +## Repo layout (key paths) + +``` +src/langbot/ +├── __main__.py # entrypoint, CLI flags (--standalone-runtime/-box/--debug) +├── pkg/ +│ ├── api/ +│ │ ├── http/ # Quart controllers + services +│ │ │ ├── controller/groups/ # route groups (@group.group_class) +│ │ │ └── service/ # business logic (called by controllers AND MCP) +│ │ └── mcp/ # MCP server (server.py = tools, mount.py = ASGI dispatch) +│ ├── core/ # app bootstrap, stages, task manager +│ ├── platform/ provider/ pipeline/ plugin/ box/ skill/ rag/ vector/ +│ ├── command/ persistence/ storage/ config/ entity/ telemetry/ +│ └── templates/config.yaml # config template (top-level: api, system, plugin, box, space...) +├── web/ # Vite SPA +└── docker/ # compose deployment +``` + +## HTTP API auth model + +Route auth is declared per-route via `AuthType` in +`pkg/api/http/controller/group.py`: + +- `NONE` — public. +- `USER_TOKEN` — web UI JWT (`Authorization: Bearer `). +- `API_KEY` — `X-API-Key` or `Authorization: Bearer `. +- `USER_TOKEN_OR_API_KEY` — either. + +API keys are verified by `apikey_service.verify_api_key()`, which accepts: +1. the **global key** from `config.yaml` `api.global_api_key` (no DB, no login, + no `lbk_` prefix required), then +2. **web-UI keys** (DB-stored, `lbk_` prefix). + +Route groups self-register via `@group.group_class(name, path)` and are +discovered by `importutil.import_modules_in_pkg`. + +## Adding an API endpoint + +1. Add/extend a controller in `pkg/api/http/controller/groups/` and the matching + service method in `pkg/api/http/service/`. +2. Pick the right `AuthType`. +3. **If the endpoint should be agent-accessible, add/adjust the matching MCP tool + in `pkg/api/mcp/server.py` and update the `langbot-mcp-ops` skill.** API and + MCP surface must stay aligned (see `AGENTS.md`). +4. Update `docs/service-api-openapi.json` if you maintain the OpenAPI overview. + +## Database migrations (Alembic) + +Single migration set supports SQLite + PostgreSQL. Files in +`src/langbot/pkg/persistence/alembic/versions/`. + +```bash +# From project root (needs data/config.yaml) +uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description" +``` + +## Standards + +- All code comments/docstrings in **English**; user-facing strings need **i18n** + (`en_US` + `zh_Hans` minimum, `ja_JP` where present). +- Consider toC and toB compatibility + security. +- Commit format: `(): ` (feat/fix/docs/refactor/...). + +## Tests + +```bash +uv run pytest tests/unit_tests -q # unit tests +uv run pytest tests/unit_tests/api -q # API service tests +uv run python tests/manual/mcp_smoke.py # MCP server e2e smoke +``` + +## See also + +- `langbot-plugin-dev` — plugin SDK / runtime development. +- `langbot-testing` — WebUI/e2e QA harness (`bin/lbs`). +- `langbot-deploy` — Docker/compose deployment + config. +- `langbot-mcp-ops` — operating the LangBot MCP server. diff --git a/skills/skills/langbot-eba-adapter-dev/SKILL.md b/skills/skills/langbot-eba-adapter-dev/SKILL.md new file mode 100644 index 000000000..b0fd180cd --- /dev/null +++ b/skills/skills/langbot-eba-adapter-dev/SKILL.md @@ -0,0 +1,301 @@ +--- +name: langbot-eba-adapter-dev +description: Build, refactor, and test LangBot platform adapters for the Event-Based Agents architecture. Use when adding or migrating Telegram, Discord, or other messaging platform adapters to the EBA adapter layout, validating unified event/message conversion, writing live adapter probes, or using standalone plugin runtime plus Computer Use for end-to-end platform testing. +--- + +# LangBot EBA Adapter Development + +Use this skill when implementing or reviewing a LangBot platform adapter under the Event-Based Agents architecture. + +## Controlling a running instance via MCP + +Beyond writing code, you can **drive a live LangBot instance over MCP** — no raw +HTTP needed. Two MCP servers exist (both reuse existing API keys; see `AGENTS.md`): + +- **LangBot instance** — `http://:5300/mcp` (auth: web-UI `lbk_` key or the + `api.global_api_key` from `config.yaml`). Manage bots, pipelines, models, + knowledge bases, and skills. See the **`langbot-mcp-ops`** skill. +- **LangBot Space marketplace** — `https://space.langbot.app/mcp` (auth: Personal + Access Token). Search plugins / MCP servers / skills. See the + **`langbot-space-ops`** skill. + +> Any change to an agent-accessible HTTP API endpoint must keep the matching MCP +> tool and these skills in sync. + +## Core Rule + +Do not let platform-native event or message shapes leak into LangBot's common path. Each adapter must convert incoming SDK objects into unified EBA entities before dispatch: + +- Events: `langbot_plugin.api.entities.builtin.platform.events` +- Message chains: `langbot_plugin.api.entities.builtin.platform.message.MessageChain` +- Users/groups/members: `langbot_plugin.api.entities.builtin.platform.entities` +- Raw platform objects may remain only in `source_platform_object` for debugging or platform-specific escape hatches. + +## Start Here + +1. Read the EBA design docs in `LangBot/docs/event-based-agents/`. +2. Read the architecture-level acceptance checklist before writing or validating code: + - `LangBot/docs/event-based-agents/adapters/acceptance-checklist.md` +3. Read the current reference adapter before writing code. Prefer Telegram first: + - `LangBot/src/langbot/pkg/platform/adapters/telegram/` + - `LangBot/docs/event-based-agents/adapters/telegram.md` +4. Read the legacy source adapter for the target platform: + - `LangBot/src/langbot/pkg/platform/sources/.py` + - `LangBot/src/langbot/pkg/platform/sources/.yaml` +5. Inspect SDK entity definitions in `langbot-plugin-sdk/src/langbot_plugin/api/entities/builtin/platform/`. +6. Search before assuming APIs. Platform SDKs change often. + +## Adapter Layout + +Create one directory per adapter: + +```text +LangBot/src/langbot/pkg/platform/adapters// +├── __init__.py +├── adapter.py +├── api_impl.py +├── event_converter.py +├── manifest.yaml +├── message_converter.py +├── platform_api.py +├── types.py +└── .svg +``` + +Add optional helpers such as `voice.py` only when the platform has a real domain-specific surface. + +Ensure `pyproject.toml` package data includes adapter assets: + +```toml +package-data = { "langbot" = ["templates/**", "pkg/platform/sources/*", "pkg/platform/adapters/**", ...] } +``` + +## Implementation Checklist + +- `manifest.yaml` declares `metadata.name`, config schema, supported events, common APIs, and platform-specific APIs. +- `adapter.py` creates the platform client, subscribes to native events, filters self/bot loops where appropriate, calls `event_converter.target2yiri(...)`, then dispatches the EBA event. +- `event_converter.py` maps native events to EBA event classes such as `MessageReceivedEvent`, `MessageEditedEvent`, `MessageDeletedEvent`, `MessageReactionEvent`, `MemberJoinedEvent`, `BotInvitedToGroupEvent`, and `PlatformSpecificEvent`. +- `message_converter.py` maps native messages to `MessageChain`, and maps `MessageChain` back to the platform send format. +- `api_impl.py` implements common EBA APIs: send, reply, edit, delete, forward, user/group/member lookup, moderation, upload/file URL, leave group. +- `platform_api.py` keeps platform-specific calls behind `call_platform_api(action, params)`. +- Unsupported common APIs must raise explicit SDK platform errors such as `NotSupportedError`; do not silently no-op. +- Destructive APIs such as kick, ban, leave, delete, or moderation must be gated in live tests and documented. + +## Conversion Contract + +For message events, the common shape should look like this regardless of platform: + +```python +platform_events.MessageReceivedEvent( + type="message.received", + adapter_name="", + message_id=, + message_chain=platform_message.MessageChain([...]), + sender=platform_entities.User(...), + chat_type=platform_entities.ChatType.PRIVATE or ChatType.GROUP, + chat_id=, + group=platform_entities.UserGroup(...) or None, + source_platform_object=, +) +``` + +Message content should use common components: + +- `Source` for original message id/time when available. +- `Plain` for text. +- `At` / `AtAll` for mentions. +- `Image`, `Voice`, `File` for media. +- `Forward` only when the platform can represent or emulate it safely. + +If a platform event cannot cleanly map to a common event, emit `PlatformSpecificEvent` with a compact `action` and structured `data`. + +## Unit Tests + +Add focused tests under `LangBot/tests/unit_tests/platform/test__eba_adapter.py`. + +Cover at least: + +- Manifest supported events match adapter `supported_events()`. +- Manifest supported APIs match adapter `supported_apis()`. +- Platform API map matches manifest actions. +- Dispatcher chooses the most specific EBA listener. +- Message converter maps every supported common component both directions where possible: + - `Source` + - `Plain` + - `At` + - `AtAll` + - `Image` + - `Voice` + - `File` + - `Quote` + - `Face` + - `Forward` + - `Unknown` + - mixed chains preserving order +- Event converter maps message received/edited/deleted/reaction, raw uncached gateway events, member events, and bot join/leave events. +- Send/reply methods pass correct platform kwargs and return `MessageResult`. + +Run the existing reference adapter tests too: + +```bash +cd LangBot +uv run pytest tests/unit_tests/platform/test__eba_adapter.py tests/unit_tests/platform/test_telegram_eba_adapter.py +uv run python -m py_compile tests/e2e/live__eba_probe.py +git diff --check +``` + +## Live Test Workflow + +Direct adapter live probes are useful diagnostics, but they are not sufficient acceptance evidence for EBA. Treat `tests/e2e/live__eba_probe.py` as an auxiliary tool only. The final adapter record must distinguish: + +- `plugin-e2e-ui`: real SDK plugin through standalone runtime, LangBot core, adapter, and a real/simulator UI action. This can mark an inbound UI item complete. +- `plugin-e2e-protocol`: real SDK plugin through standalone runtime, LangBot core, adapter, and a protocol-boundary injected event. This is useful evidence but must not be claimed as UI coverage. +- `plugin-e2e-outbound`: real SDK plugin calls an API and the bot output is visible in the real/simulator UI. This can mark send/API coverage complete. +- `adapter-live`: direct adapter probe connected to a real/simulator endpoint. This is auxiliary only. +- `unit`: mocked conversion/API-shape coverage. This is auxiliary only. +- `not-supported`: platform protocol or SDK has no equivalent. Must include the reason. +- `blocked`: intended capability could not be verified. This is not complete. + +Write a live probe in `LangBot/tests/e2e/live__eba_probe.py`. It should: + +1. Read token/client ids from environment variables or CLI args. +2. Start the adapter directly. +3. Register an EBA listener and write JSONL evidence to `LangBot/data/temp/`. +4. Wait for a real user/platform event instead of fabricating the entrypoint. +5. Exercise common APIs and `call_platform_api` actions. +6. Observe returned gateway events for edit/delete/reaction/member/bot lifecycle where available. +7. Print a summary containing passed, failed, skipped, and observed event types. +8. Redact or avoid printing secrets. +9. Keep destructive operations behind flags and run them last. + +Use Computer Use when the user asks for real platform end-to-end coverage. Actually send messages/click reactions in the platform UI or otherwise trigger real user-side events; do not replace that with unit tests. + +For media/component acceptance, keep the direction and trigger source explicit: + +- Real inbound media only counts when a human-side platform UI or simulator UI sends the image/file/voice to the bot and the plugin JSONL records the corresponding common component. +- Bot outbound media only proves `send_message`/adapter send conversion. It does not prove inbound conversion. +- Protocol-boundary injection, such as sending a OneBot event directly into a reverse WebSocket adapter, is useful and should be labelled `plugin-e2e-protocol`, but it must not be reported as UI-level end-to-end media upload. +- If the UI cannot send or upload the media, record the item as `blocked` with the exact client/simulator limitation. + +## Standalone Runtime + Plugin Test + +When validating the whole LangBot EBA path, test with the SDK standalone runtime and a real test plugin. This is the required acceptance path; direct adapter calls do not prove the EBA architecture path. + +The required path is: + +```text +Real platform / simulator UI + -> platform SDK native event + -> adapter event converter + -> unified EBA event/entity/message types + -> LangBot core event dispatch + -> standalone SDK runtime + -> real test plugin listener + -> plugin calls platform APIs through SDK + -> LangBot core API dispatch + -> adapter API implementation + -> real platform / simulator UI +``` + +Typical shape: + +```bash +# Terminal 1, SDK repo +cd langbot-plugin-sdk +uv run python -m langbot_plugin.cli.__init__ rt \ + --debug-only \ + --ws-control-port 5400 \ + --ws-debug-port 5401 \ + --skip-deps-check + +# Terminal 2, LangBot repo +cd LangBot +export PYTHONPATH=/absolute/path/to/langbot-plugin-sdk/src:${PYTHONPATH:-} +uv run main.py --standalone-runtime + +# Terminal 3, plugin directory +export DEBUG_RUNTIME_WS_URL=ws://127.0.0.1:5401/plugin/ws +export EBA_PROBE_LOG=/absolute/path/to/LangBot/data/temp/_eba_plugin_probe.jsonl +export EBA_PROBE_API=1 +export EBA_PROBE_COMPONENT_SWEEP=1 +export EBA_PROBE_PLATFORM_API=1 +uv --project /absolute/path/to/langbot-plugin-sdk run python -m langbot_plugin.cli.__init__ run +``` + +Use an EBA probe plugin that subscribes to all relevant EBA event classes and runs SDK API calls after the first `MessageReceived`. + +The plugin evidence should be JSONL and include: + +- event class and `event.type` +- adapter name +- chat type and chat ID +- sender/user/group IDs with secrets redacted +- `bot_uuid` and `adapter_name`, proving LangBot filled common routing fields before plugin dispatch +- received `message_chain` component list +- API action name, input summary, result or error +- unsupported or blocked reason when an item is skipped + +For full adapter acceptance, enable both probe sweeps: + +- `EBA_PROBE_COMPONENT_SWEEP=1` sends the required outbound message components through `send_message`. +- `EBA_PROBE_PLATFORM_API=1` calls common safe APIs plus selected `call_platform_api` actions for the adapter. + +The SDK must support `plugin.call_platform_api(bot_uuid, action, params)` for platform-specific acceptance. If the SDK cannot call a platform-specific action from the plugin, the adapter cannot be fully accepted even if direct adapter probes pass. + +## Required EBA Acceptance Coverage + +Before marking an adapter migrated, fill out an adapter record against `LangBot/docs/event-based-agents/adapters/acceptance-checklist.md`. + +At minimum, the record must cover these categories: + +- Message receive component tests through `plugin-e2e-ui`: `Source`, `Plain`, `At`, `AtAll`, `Image`, `Voice`, `File`, `Quote`, `Face`, `Forward`, `Unknown`, and mixed chains where the platform supports them. Protocol-only receive evidence must be labelled `plugin-e2e-protocol`. +- Message send component tests through `plugin-e2e-outbound`: `Plain`, `At`, `AtAll`, `Image`, `Voice`, `File`, `Quote`, `Face`, `Forward`, and mixed chains where the platform supports them. +- Every event declared in `manifest.yaml -> spec.supported_events`. +- Every common API declared in `manifest.yaml -> spec.supported_apis.required` and `optional`. +- Every action declared in `manifest.yaml -> spec.platform_specific_apis`. +- Compatibility tests for manifest declarations, legacy message listener fallback, EBA listener specificity, bot self-message filtering, and `source_platform_object` reply/debug behavior. + +Do not declare an event or API in the manifest unless it has an implementation path and an acceptance entry. If a platform or simulator lacks a capability, document it as `not-supported` or `blocked` rather than silently omitting the test. + +## Common Pitfalls + +- `get_bots()` may return bot dictionaries, not UUID strings. Probe plugins should select an enabled dict and pass `bot["uuid"]` to `get_bot_info()` and `send_message()`. +- Make sure the probe subscribes to every event you claim to verify. Missing `MessageDeleted` subscription can make a working adapter look untested. +- Some platforms emit both cached and raw gateway events, producing duplicate evidence for delete/reaction. Count this explicitly; do not treat duplicates as failure unless semantics differ. +- Self-message filtering is platform-specific. Filter bot-originated `message.received` loops, but do not accidentally filter edit/delete events needed for bot-owned API probes. +- Reaction events may be filtered for bot self reactions. To test user reaction add/remove, use real UI interaction or a real user token path if permitted. +- File uploads usually happen as message attachments. A standalone `upload_file` API may need to be `NotSupportedError`. +- Live probes should not leak bot tokens through command output, logs, docs, or final answers. +- Discord requires privileged intents for message content and members. Missing intents can look like converter bugs. +- Telegram Bot API exposes only limited member lists; document capability gaps. +- Do not mark moderation APIs verified unless they ran against a disposable target member/bot. +- If `leave_group` is tested, run it last because the test bot will be removed from the server/group. +- Restore local LangBot DB/test state after live runs if you enabled temporary bots or changed plugin settings. + +## Documentation Record + +Add or update `LangBot/docs/event-based-agents/adapters/.md` in the same style as Telegram: + +- Status and adapter directory. +- Configuration table matching manifest fields. +- Supported EBA event list. +- Common API table with support and limitations. +- `call_platform_api` action list. +- Receive component table with evidence level per component. +- Send component table with evidence level per component. +- Event table with evidence level per event. +- Common API table with evidence level per API. +- Platform-specific API table with evidence level per action. +- Live test record with exact date, endpoint/simulator, standalone runtime command, test plugin path/name, JSONL evidence path, channel/group type, observed events, APIs exercised, destructive operations, and skipped items. + +Be honest. Put untested or skipped APIs in the document with the reason. Do not imply full parity when a platform cannot provide the same information density. + +## Before Finishing + +- Run unit tests and compile the live probe. +- Run the standalone runtime plugin E2E path for every required acceptance item that the platform supports. +- Run `git diff --check`. +- Summarize live JSONL evidence by event type. +- Stop all long-running runtimes and probes. +- Confirm no secrets are staged. +- Leave unrelated untracked files alone. diff --git a/skills/skills/langbot-env-setup/SKILL.md b/skills/skills/langbot-env-setup/SKILL.md new file mode 100644 index 000000000..887a46713 --- /dev/null +++ b/skills/skills/langbot-env-setup/SKILL.md @@ -0,0 +1,28 @@ +--- +name: langbot-env-setup +description: Prepare a local LangBot development and testing environment for an AI agent. Use when setting up WSL or Linux development, shared local URL variables, proxy variables, backend/frontend startup, Playwright MCP browser access, GitHub OAuth browser login, persisted Chrome profiles, or future Codex computer-use environment paths. +--- + +# LangBot Environment Setup + +Use this skill when a task needs LangBot to be in a testable state before product testing or development verification. + +## Routing + +- **Shared local variables**: read `../.env` before using URL, path, browser profile, or proxy defaults. +- **Always start here**: read `references/browser-access-selection.md` to choose the browser-control path. +- **LangBot service checks and startup**: read `references/service-startup.md`. +- **Computer Use available**: read `references/computer-use.md`. This path usually needs less browser/MCP setup. +- **No Computer Use, browser automation required**: read `references/playwright-mcp.md`. +- **GitHub OAuth or persisted login profile**: read `references/oauth-browser-profile.md`. +- **WSL-specific notes**: read `references/wsl-notes.md` only when running under WSL. +- **Proxy setup**: read `references/proxy.md` when external login, model provider tests, or package downloads time out. +- **Headless-only automation**: use only after a profile already contains a valid LangBot login. Do not ask the agent to enter GitHub credentials or 2FA. + +## Rules + +- Never handle the user's GitHub password, passkey, recovery code, or 2FA secret. +- For OAuth login, open a visible browser and let the user complete the credential steps. +- Reuse a fixed browser profile path so the agent can later access the logged-in LangBot session. +- Keep environment-specific paths and commands in `references/`, not in this file. +- Treat environment setup as complete only after the target LangBot services are reachable and the browser profile can access the WebUI. diff --git a/skills/skills/langbot-env-setup/references/browser-access-selection.md b/skills/skills/langbot-env-setup/references/browser-access-selection.md new file mode 100644 index 000000000..d1f98217e --- /dev/null +++ b/skills/skills/langbot-env-setup/references/browser-access-selection.md @@ -0,0 +1,15 @@ +# Browser Access Selection + +Choose the lightest browser-control path that can complete the task. + +## Decision Order + +1. If Codex Computer Use, Claude Computer Use, or another visible browser-control tool is available, use `computer-use.md`. +2. If no computer-control tool is available but Playwright MCP is available, use `playwright-mcp.md`. +3. If the browser session must survive restarts or OAuth login is required, also use `oauth-browser-profile.md`. +4. If running under WSL, add `wsl-notes.md`. +5. If external sites or model providers time out, add `proxy.md`. + +## Principle + +Computer Use and Playwright MCP are alternative browser-control paths. Both still need LangBot services to be reachable, so service checks stay in `service-startup.md`. diff --git a/skills/skills/langbot-env-setup/references/computer-use.md b/skills/skills/langbot-env-setup/references/computer-use.md new file mode 100644 index 000000000..f0bd8b182 --- /dev/null +++ b/skills/skills/langbot-env-setup/references/computer-use.md @@ -0,0 +1,20 @@ +# Computer Use Browser Path + +Use this path when Codex Computer Use, Claude Computer Use, or another agent-visible browser-control capability is available. + +## Why This Path Is Simpler + +Computer Use can interact with a visible browser directly, so it usually does not need Playwright MCP configuration or a separate MCP browser bridge. + +## Workflow + +1. Verify LangBot backend/frontend with `service-startup.md`. +2. Open the WebUI in the controlled browser. +3. If login is needed, let the user complete GitHub OAuth. Never handle credentials or 2FA. +4. Keep the browser/profile available for later testing. +5. Hand off to `langbot-testing` after the page shows the logged-in WebUI. + +## Still Required + +- Proxy may still be needed for GitHub OAuth or model provider tests. Use `proxy.md`. +- Persisted profile details may still matter if the computer-control browser is restarted. Use `oauth-browser-profile.md` if login state must survive. diff --git a/skills/skills/langbot-env-setup/references/oauth-browser-profile.md b/skills/skills/langbot-env-setup/references/oauth-browser-profile.md new file mode 100644 index 000000000..b3ac8a91e --- /dev/null +++ b/skills/skills/langbot-env-setup/references/oauth-browser-profile.md @@ -0,0 +1,62 @@ +# OAuth Browser Profile + +Use this reference when LangBot or LangBot Space needs GitHub OAuth login and the agent must reuse the authenticated browser state later. + +Read `skills/.env` first for `LANGBOT_BACKEND_URL`, `LANGBOT_FRONTEND_URL`, `LANGBOT_BROWSER_PROFILE`, `LANGBOT_CHROMIUM_EXECUTABLE`, and proxy defaults. + +## Rules + +- Never handle the user's GitHub password, passkey, recovery code, or 2FA secret. +- Open a visible browser and let the user complete credential steps. +- Reuse a fixed browser profile path. +- Do not print token values. It is acceptable to report localStorage key names. + +## Manual Visible Login Flow + +1. Verify LangBot backend is reachable with `service-startup.md`. +2. Launch a visible Chromium window with the persistent profile: + +```bash +setsid "$LANGBOT_CHROMIUM_EXECUTABLE" \ + --no-sandbox \ + --ozone-platform=x11 \ + --user-data-dir="$LANGBOT_BROWSER_PROFILE" \ + --proxy-server="$LANGBOT_PROXY_SOCKS" \ + --proxy-bypass-list="$LANGBOT_NO_PROXY" \ + "$LANGBOT_BACKEND_URL/login" \ + >/tmp/langbot-visible-chrome.log 2>&1 < /dev/null & +``` + +3. The user completes: + +```text +Login with Space -> Login with GitHub -> GitHub credentials / 2FA -> authorize +``` + +4. The agent can then reuse the same profile for automated checks. + +## Expected Successful State + +After login, LangBot should redirect away from `/login`, for example to a `/home/...` URL on the selected origin. + +Expected visible signals: + +```text +LangBot +Dashboard +Home +Bots +Pipelines +Knowledge +Extensions +``` + +Expected localStorage key names: + +```text +token +userEmail +langbot_language +``` + +If the user logged in on one origin but `LANGBOT_FRONTEND_URL` still shows `/login`, copy only the auth state needed between origins. Do not print token values. diff --git a/skills/skills/langbot-env-setup/references/playwright-mcp.md b/skills/skills/langbot-env-setup/references/playwright-mcp.md new file mode 100644 index 000000000..3e460e279 --- /dev/null +++ b/skills/skills/langbot-env-setup/references/playwright-mcp.md @@ -0,0 +1,30 @@ +# Playwright MCP Browser Path + +Use this path when the agent needs browser automation but no Computer Use browser-control path is available. + +## Known Paths + +- Persistent browser profile: `LANGBOT_BROWSER_PROFILE` from `skills/.env.local` +- Chromium executable: `LANGBOT_CHROMIUM_EXECUTABLE` from `skills/.env.local` +- Codex MCP config: `$CODEX_HOME/config.toml` or the config path used by the active agent. + +## MCP Config + +Keep the profile path fixed so the agent can reuse authenticated state. + +```toml +[mcp_servers.playwright] +command = "npx" +args = ["-y", "@playwright/mcp@latest", "--no-sandbox", "--executable-path", "", "--proxy-server", "", "--proxy-bypass", "localhost,127.0.0.1", "--user-data-dir", ""] +``` + +After changing MCP config, restart Codex so the MCP server is relaunched with the new args. + +## Visible Login + +For OAuth login, Playwright MCP's headless browser is not enough. Launch a visible browser with the same profile and let the user complete login. Use `oauth-browser-profile.md`. + +## Common Failures + +- MCP still uses old args after editing config: restart Codex or kill old `playwright-mcp` processes and restart the session. +- Browser is headless during OAuth: use the visible login command from `oauth-browser-profile.md`. diff --git a/skills/skills/langbot-env-setup/references/proxy.md b/skills/skills/langbot-env-setup/references/proxy.md new file mode 100644 index 000000000..14b276464 --- /dev/null +++ b/skills/skills/langbot-env-setup/references/proxy.md @@ -0,0 +1,30 @@ +# Proxy Setup + +Use this reference when GitHub OAuth, package installation, model provider tests, or external API calls time out. + +Read defaults from `skills/.env` first. + +## Standard Local Proxy + +```bash +export HTTP_PROXY="$LANGBOT_PROXY_HTTP" +export HTTPS_PROXY="$LANGBOT_PROXY_HTTP" +export ALL_PROXY="$LANGBOT_PROXY_SOCKS" +export http_proxy="$LANGBOT_PROXY_HTTP" +export https_proxy="$LANGBOT_PROXY_HTTP" +export all_proxy="$LANGBOT_PROXY_SOCKS" +export NO_PROXY="$LANGBOT_NO_PROXY" +export no_proxy="$LANGBOT_NO_PROXY" +``` + +## Rule + +Keep uppercase and lowercase proxy variables consistent. Different libraries read different names. + +## Checks + +```bash +env | rg -i '^(http|https|all|no)_?proxy=' +curl -I --max-time 8 --proxy "$LANGBOT_PROXY_SOCKS" https://github.com +curl -I --max-time 3 "$LANGBOT_BACKEND_URL" +``` diff --git a/skills/skills/langbot-env-setup/references/service-startup.md b/skills/skills/langbot-env-setup/references/service-startup.md new file mode 100644 index 000000000..b63960cdb --- /dev/null +++ b/skills/skills/langbot-env-setup/references/service-startup.md @@ -0,0 +1,77 @@ +# Service Startup + +Use this reference for LangBot backend/frontend readiness checks regardless of OS or browser-control method. Read `skills/.env` first and override those defaults with user-provided values or detected running services. + +## Variables + +- `LANGBOT_REPO` +- `LANGBOT_WEB_REPO` +- `LANGBOT_BACKEND_URL` +- `LANGBOT_FRONTEND_URL` +- `LANGBOT_DEV_FRONTEND_URL` + +## Backend + +Start LangBot from the backend repo: + +```bash +cd "$LANGBOT_REPO" +uv run main.py +``` + +Healthy startup includes: + +```text +Running on http://0.0.0.0: +Connected to plugin runtime. +Plugin langbot/local-agent initialized +``` + +Quick check: + +```bash +curl -I --max-time 3 "$LANGBOT_BACKEND_URL/login" +``` + +If `bin/lbs env doctor` reports that `LANGBOT_BACKEND_URL` has no TCP listener, +the backend is not running at the configured host and port. A reachable +standalone frontend on `LANGBOT_FRONTEND_URL` does not prove backend readiness. + +Prefer a visible terminal session while debugging backend startup. Detached +background startup methods can hide early process exits in local agent runs; if +you use one, immediately verify both the process and the listener: + +```bash +ps -eo pid,cmd | rg 'main.py|uv run main|langbot' +ss -ltnp | rg ':5300' +curl -I --max-time 3 "$LANGBOT_BACKEND_URL/login" +``` + +## Frontend + +Start the new frontend from the web repo: + +```bash +cd "$LANGBOT_WEB_REPO" +VITE_API_BASE_URL="$LANGBOT_BACKEND_URL" pnpm dev --host 0.0.0.0 +``` + +Healthy startup includes: + +```text +Local: +``` + +Quick check: + +```bash +curl -I --max-time 3 "$LANGBOT_FRONTEND_URL" +``` + +If `VITE_API_BASE_URL` is missing, Vite still serves the page but frontend API +calls may go to the frontend port instead of the backend port. That produces +false browser failures in login, wizard, pipeline, and Debug Chat cases. + +## Completion Signal + +Environment setup is not complete until the required frontend/backend URLs are reachable and the chosen browser-control path can open the WebUI. diff --git a/skills/skills/langbot-env-setup/references/wsl-notes.md b/skills/skills/langbot-env-setup/references/wsl-notes.md new file mode 100644 index 000000000..6b12b020c --- /dev/null +++ b/skills/skills/langbot-env-setup/references/wsl-notes.md @@ -0,0 +1,36 @@ +# WSL Notes + +Use this reference only for WSL-specific details. Do not put generic LangBot startup or browser-login steps here. + +## Network + +GitHub login and model provider calls may require proxy access from WSL. + +Working proxy form: + +```bash +socks5://127.0.0.1:7890 +``` + +Bypass local LangBot: + +```bash +localhost,127.0.0.1 +``` + +Quick checks: + +```bash +curl -I --max-time 8 --proxy socks5h://127.0.0.1:7890 https://github.com +curl -I --max-time 3 "$LANGBOT_BACKEND_URL" +``` + +## Visible Browser + +If OAuth requires a visible browser, WSL must have a usable display path. If a visible Chromium launch fails, check the local WSL GUI/X11 setup before changing LangBot config. + +## Common Failures + +- `ERR_NETWORK_CHANGED` or GitHub timeout: browser is not using the SOCKS proxy. +- LangBot connection refused: backend is not running or not reachable from WSL. +- User cannot type credentials: browser is headless or not visible to the user. diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md new file mode 100644 index 000000000..94f25a3a4 --- /dev/null +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -0,0 +1,99 @@ +--- +name: langbot-mcp-ops +description: Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on "langbot mcp", "manage langbot via mcp", "langbot /mcp", "langbot mcp server". +--- + +# LangBot MCP Operations + +LangBot exposes an **MCP server** so AI agents can manage an instance +programmatically. It mirrors a curated subset of the HTTP service API. + +## Endpoint + +``` +http://:5300/mcp +``` + +Transport: **streamable HTTP** (stateless, JSON responses). Same host/port as +the web UI and HTTP API. + +## Authentication + +Reuses the same API keys as the HTTP API. Send either header: + +``` +X-API-Key: +# or +Authorization: Bearer +``` + +Two kinds of key are accepted: + +1. **Web-UI key** — created in the web UI (sidebar → API Keys), prefixed `lbk_`, + stored in the database. +2. **Global API key** — set in `data/config.yaml` under `api.global_api_key`. + Requires no login session and no DB record; does not need the `lbk_` prefix. + Leave empty to disable. See the `langbot-deploy` skill for config details. + +Requests without a valid key get `401 Unauthorized`. + +## Client configuration + +```json +{ + "mcpServers": { + "langbot": { + "url": "http://:5300/mcp", + "headers": { "X-API-Key": "" } + } + } +} +``` + +## Tool surface + +The tools wrap the LangBot service layer. Current tools (v1): + +| Tool | Purpose | +| --- | --- | +| `get_system_info` | Version, edition, instance id | +| `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) | +| `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines | +| `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers | +| `list_knowledge_bases` / `get_knowledge_base` / `retrieve_knowledge_base` | RAG knowledge bases (incl. semantic search) | +| `list_mcp_servers` | External MCP servers LangBot connects to (as a client) | +| `list_skills` / `get_skill` | Installed skills | + +Mutating tools (`create_*`, `update_*`) take a JSON object matching the same +shape as the corresponding HTTP API request body. Discover resources with the +`list_*` / `get_*` tools before mutating; identifiers are UUIDs. + +## How to use + +1. Get an API key (web UI key, or set `api.global_api_key` in config.yaml). +2. Point your MCP client at `http://:5300/mcp` with the key header. +3. Call `get_system_info` to confirm connectivity. +4. Use `list_*` tools to discover, then `get_*` / `create_*` / `update_*` / + `delete_*` as needed. + +## Implementation & maintenance (for LangBot developers) + +- Server: `src/langbot/pkg/api/mcp/server.py` (FastMCP). Tools call the service + layer directly, so the MCP surface stays aligned with the API. +- Mount: `src/langbot/pkg/api/mcp/mount.py` — an ASGI dispatcher fronting Quart, + authenticating `/mcp` requests, running the streamable-HTTP session manager. +- Smoke test: `tests/manual/mcp_smoke.py`. + +> When you add, remove, or change an HTTP API endpoint that should be +> agent-accessible, update the corresponding MCP tool **and** this skill. The +> MCP tool surface and the API must stay aligned (see `AGENTS.md`). + +## Pitfalls + +- `/mcp` is the **server** LangBot exposes. The `/api/v1/mcp` routes are the + **client** side (managing external MCP servers LangBot connects to). Don't + confuse them. +- A `401` means the key is wrong, missing, or (for the global key) + `api.global_api_key` is empty in config.yaml. +- The global key is plaintext in config.yaml — only enable it on trusted/internal + deployments and serve over HTTPS. diff --git a/skills/skills/langbot-plugin-dev/SKILL.md b/skills/skills/langbot-plugin-dev/SKILL.md new file mode 100644 index 000000000..d7858c40e --- /dev/null +++ b/skills/skills/langbot-plugin-dev/SKILL.md @@ -0,0 +1,484 @@ +--- +name: langbot-plugin-dev +description: Develop, debug, and test LangBot plugins. Use when creating new LangBot plugins, fixing plugin bugs, setting up a LangBot test environment, or testing plugins via WebSocket. Covers plugin component architecture (EventListener, Command, Tool), the plugin SDK API (invoke_llm, get_llm_models, send_message, plugin storage), common pitfalls, and automated WebSocket-based testing. Triggers on "langbot plugin", "lbp", "GroupChatSummary", "plugin debug", "langbot test". +--- + +# LangBot Plugin Development & Debugging + +## Controlling a running instance via MCP + +Beyond writing code, you can **drive a live LangBot instance over MCP** — no raw +HTTP needed. Two MCP servers exist (both reuse existing API keys; see `AGENTS.md`): + +- **LangBot instance** — `http://:5300/mcp` (auth: web-UI `lbk_` key or the + `api.global_api_key` from `config.yaml`). Manage bots, pipelines, models, + knowledge bases, and skills. See the **`langbot-mcp-ops`** skill. +- **LangBot Space marketplace** — `https://space.langbot.app/mcp` (auth: Personal + Access Token). Search plugins / MCP servers / skills. See the + **`langbot-space-ops`** skill. + +> Any change to an agent-accessible HTTP API endpoint must keep the matching MCP +> tool and these skills in sync. + +## Plugin Architecture + +A LangBot plugin consists of: + +``` +MyPlugin/ +├── manifest.yaml # Plugin metadata, config schema +├── main.py # BasePlugin subclass (entry point, shared state) +├── components/ +│ ├── event_listener/ # Hook pipeline events +│ │ ├── collector.yaml +│ │ └── collector.py +│ ├── commands/ # !command handlers +│ │ ├── mycommand.yaml +│ │ └── mycommand.py +│ └── tools/ # LLM function-call tools +│ ├── mytool.yaml +│ └── mytool.py +``` + +Each component has a `.yaml` (metadata) and `.py` (implementation). + +## README & i18n convention (enforced on the marketplace) + +A plugin published to LangBot Space serves a localized README on its detail page. +The resolver (`langbot-space` `PluginService.GetPluginREADME`) works like this: + +- **Root `README.md` MUST be in English.** It is the default and the fallback — + when no per-language README matches the viewer's locale, the page serves the + root `README.md`. A non-English root README makes the English/default view show + the wrong language. +- **All other languages live under `readme/README_{lang}.md`** — e.g. + `readme/README_zh_Hans.md`, `readme/README_ja_JP.md`. The 8 supported locales: + `en_US, zh_Hans, zh_Hant, ja_JP, th_TH, vi_VN, es_ES, ru_RU`. +- `manifest.yaml` `metadata.label` / `metadata.description` should carry the same + 8-locale i18n set (`repository` must be a real, alive URL). + +``` +MyPlugin/ +├── manifest.yaml +├── README.md # English (default + fallback) — REQUIRED, must be English +└── readme/ + ├── README_zh_Hans.md + ├── README_zh_Hant.md + ├── README_ja_JP.md + ├── README_th_TH.md + ├── README_vi_VN.md + ├── README_es_ES.md + └── README_ru_RU.md +``` + +`manifest.yaml` (incl. `repository`) is the source of truth — the marketplace +syncs from it, so edit the package and re-publish rather than patching live data. + +## Critical SDK Pitfalls + +### 1. MessageChain is a RootModel — iterate directly + +```python +# ❌ WRONG — MessageChain has no .components attribute +for component in event.message_chain.components: + +# ✅ CORRECT — MessageChain is a Pydantic RootModel, iterate directly +for component in event.message_chain: +``` + +### 2. Message.content must be `list[ContentElement]` or `str`, not a single ContentElement + +```python +from langbot_plugin.api.entities.builtin.provider import message as provider_message + +# ❌ WRONG — single ContentElement +Message(role="user", content=ContentElement.from_text("hello")) + +# ✅ CORRECT — list of ContentElement +Message(role="user", content=[ContentElement.from_text("hello")]) + +# ✅ ALSO CORRECT — plain string +Message(role="user", content="hello") +``` + +### 3. invoke_llm does NOT accept timeout + +```python +# ❌ WRONG +await self.invoke_llm(llm_model_uuid=uuid, messages=msgs, timeout=60) + +# ✅ CORRECT +await self.invoke_llm(llm_model_uuid=uuid, messages=msgs) +``` + +### 4. invoke_llm response.content can be str OR list + +```python +response = await self.invoke_llm(...) +if response.content: + if isinstance(response.content, str): + return response.content + elif isinstance(response.content, list): + parts = [e.text for e in response.content if hasattr(e, "text") and e.text] + return "\n".join(parts) +``` + +### 5. get_llm_models() returns UUIDs + +```python +# Returns list[str] of model UUIDs +models = await self.get_llm_models() +model_uuid = models[0] # First available model UUID +``` + +**Known bug (v4.9.3):** The host handler may return `list[dict]` instead of `list[str]`. If you hit `TypeError: unhashable type: 'dict'` in `invoke_llm`, the fix is in `LangBot/src/langbot/pkg/plugin/handler.py` — change `'llm_models': llm_models` to `'llm_models': [m['uuid'] for m in llm_models]`. + +### 6. invoke_llm parameter is `llm_model_uuid`, NOT `model_uuid` + +```python +# ❌ WRONG — will throw "got an unexpected keyword argument" +await self.invoke_llm(messages=msgs, model_uuid=uuid) + +# ✅ CORRECT +await self.invoke_llm(messages=msgs, llm_model_uuid=uuid) +``` + +### 7. prevent_default() alone does NOT block LLM response + +To fully prevent the default LLM pipeline from responding when your EventListener handles the message, you must call **both**: + +```python +event_context.prevent_default() # Block default behavior +event_context.prevent_postorder() # Block subsequent plugins/pipeline +``` + +Using only `prevent_default()` still allows the LLM to generate a response. + +### 8. get_plugin_storage / set_plugin_storage may throw KeyError: 'owner' + +This is a version mismatch between the SDK and host. Wrap storage calls in try/except: + +```python +try: + data = await self.get_plugin_storage("my_key") +except Exception: + data = None # Fallback gracefully +``` + +### 9. Component YAML must have full structure, not just name/description + +```yaml +# ❌ WRONG — will silently fail to register the component +name: translator +description: + en_US: 'Does stuff' + +# ✅ CORRECT — full component YAML +apiVersion: v1 +kind: EventListener +metadata: + name: translator + label: + en_US: Translator +spec: +execution: + python: + path: translator.py + attr: Translator +``` + +### 10. BasePlugin import path + +```python +# ❌ WRONG +from langbot_plugin.api.definition.base_plugin import BasePlugin + +# ✅ CORRECT +from langbot_plugin.api.definition.plugin import BasePlugin +``` + +## Pipeline Events + +Events the EventListener can hook (from most general to most specific): + +| Event | When | +|---|---| +| `GroupMessageReceived` | **Any** group message arrives (before trigger rules) | +| `PersonMessageReceived` | **Any** private message arrives | +| `GroupNormalMessageReceived` | Group message passes trigger rules, going to LLM | +| `PersonNormalMessageReceived` | Private message going to LLM | +| `GroupCommandSent` | Group message matched as command | +| `PersonCommandSent` | Private message matched as command | +| `NormalMessageResponded` | LLM generated a response | +| `PromptPreProcessing` | About to build LLM context | + +**Key insight:** `*MessageReceived` fires for ALL messages regardless of trigger rules. `*NormalMessageReceived` only fires for messages that match the pipeline's trigger rules (e.g., @bot, prefix, random%). Use `*MessageReceived` for message collection/logging. + +## EventContext API + +```python +@self.handler(events.GroupMessageReceived) +async def on_msg(event_context: context.EventContext): + event = event_context.event + event.launcher_id # Group ID + event.sender_id # Sender ID + event.message_chain # MessageChain (iterate directly) + + # Reply to the current conversation + await event_context.reply(MessageChain([Plain(text="hello")])) + + # Block default pipeline behavior + event_context.prevent_default() + + # Block subsequent plugins + event_context.prevent_postorder() +``` + +## Setting Up a Test Environment + +### Deploy via Docker (GitOps + Portainer) + +See `references/test-env-setup.md` for full deployment steps. + +Quick summary: +1. Create `docker-compose.yaml` in `server-deploy` repo +2. Deploy via Portainer git repository method +3. Set up admin account via `/api/v1/user/init` POST +4. Configure LLM provider and model via API +5. Copy plugin to `data/plugins/` directory + +### WebSocket Testing + +LangBot's WebUI chat uses WebSocket. Connect to test message flow: + +``` +ws://:/api/v1/pipelines//ws/connect?session_type=group +``` + +- `session_type=group` for group chat simulation +- `session_type=person` for private chat (always triggers pipeline) + +**Requires Origin header** to pass CORS: +```javascript +const ws = new WebSocket(url, { + headers: { Origin: 'https://your-langbot-domain' } +}); +``` + +Send messages: +```json +{"type": "message", "message": [{"type": "Plain", "text": "hello"}]} +``` + +Receive: +- `{"type": "connected", ...}` — connection established +- `{"type": "user_message", "data": {...}}` — echo of sent message +- `{"type": "response", "data": {"content": "...", "is_final": true/false}}` — bot reply (streamed) + +### Group Trigger Rules + +Group messages only enter the pipeline if trigger rules are met: + +```json +{ + "group-respond-rules": { + "at": true, // Respond when @bot + "prefix": ["ai"], // Respond to messages starting with "ai" + "random": 0.0, // Probability of responding to any message (0.0-1.0) + "regexp": [] // Regex patterns + } +} +``` + +For testing, set `random: 1.0` via PUT `/api/v1/pipelines/` to respond to all messages. + +**Important:** EventListener hooks like `GroupMessageReceived` fire regardless of trigger rules. Only the LLM processing (`GroupNormalMessageReceived` and beyond) requires trigger rules. + +### Plugin Hot-Reload + +There is **no hot-reload**. After changing plugin files: + +```bash +docker restart +# Wait ~5 seconds for plugin to re-mount +``` + +The main LangBot container does NOT need restart for plugin changes — only the runtime container. + +## API Quick Reference + +### Admin Setup + +```bash +# Initialize admin account (first time only) +curl -X POST $BASE/api/v1/user/init \ + -H "Content-Type: application/json" \ + -d '{"user":"admin@test.com","password":"test123"}' + +# Login +curl -X POST $BASE/api/v1/user/auth \ + -H "Content-Type: application/json" \ + -d '{"user":"admin@test.com","password":"test123"}' +# Returns: {"data":{"token":"eyJ..."}} +``` + +### Provider & Model Setup + +```bash +# Create provider +curl -X POST $BASE/api/v1/provider/providers \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"MyProvider","requester":"new-api-chat-completions","base_url":"https://api.example.com/v1","api_keys":["sk-xxx"]}' + +# Create LLM model +curl -X POST $BASE/api/v1/provider/models/llm \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"gpt-4o-mini","provider_uuid":"","abilities":["chat","tool-use"]}' + +# List models +curl $BASE/api/v1/provider/models/llm -H "Authorization: Bearer $TOKEN" +``` + +### Pipeline Config + +```bash +# Get pipeline +curl $BASE/api/v1/pipelines -H "Authorization: Bearer $TOKEN" + +# Update pipeline (e.g., set model, modify trigger rules) +curl -X PUT $BASE/api/v1/pipelines/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '' +``` + +## Plugin Config Types + +Supported `type` values in `manifest.yaml` `spec.config`: + +| Type | Description | Value | +|---|---|---| +| `string` | Text input | string | +| `int` / `integer` | Number input | int | +| `float` | Decimal input | float | +| `bool` / `boolean` | Toggle | bool | +| `select` | Dropdown (needs `options`) | string | +| `prompt-editor` | Multi-line prompt editor | string | +| `llm-model-selector` | LLM model picker UI | UUID string | +| `bot-selector` | Bot picker UI | UUID string | + +Example — let users choose which model the plugin uses: + +```yaml +spec: + config: + - name: model + type: llm-model-selector + label: + en_US: 'LLM Model' + zh_Hans: 'LLM 模型' + description: + en_US: 'Select the LLM model. Falls back to first available if not set.' + zh_Hans: '选择 LLM 模型。未设置时使用第一个可用模型。' + required: false +``` + +Read config in plugin code: + +```python +model_uuid = self.get_config().get("model") +``` + +## Container Restart Timing + +After plugin file changes, **only the runtime container needs restart**: + +```bash +docker restart langbot-test-runtime +# Wait ~15 seconds before testing +``` + +**When to restart both (runtime first, then host):** +- Added/removed Command or Tool components (host caches component lists) +- Changed `manifest.yaml` structure + +```bash +docker restart langbot-test-runtime +sleep 8 +docker restart langbot-test +sleep 8 +``` + +**⚠️ Do NOT restart both simultaneously** — the host may connect before plugins are mounted, causing 502 errors or missing plugin registrations. + +## Debugging Checklist + +When a plugin doesn't work: + +1. **Check runtime logs**: `docker logs ` — look for mount/init errors +2. **Check host logs**: `docker logs ` — look for pipeline processing errors +3. **Verify plugin loaded**: `GET /api/v1/plugins` — should list your plugin +4. **Test person mode first**: `session_type=person` always triggers pipeline, isolating trigger rule issues +5. **Check trigger rules**: Group mode requires @bot, prefix match, or random% to enter pipeline +6. **Verify model configured**: Pipeline's `config.ai.local-agent.model.primary` must point to a valid model UUID with working API keys + +## Publishing Plugins + +After testing, publish via `lbp publish`: + +```bash +cd /path/to/MyPlugin +lbp publish +``` + +This builds `.lbpkg` and uploads to Space marketplace as a draft. Then go to https://space.langbot.app/market to upload screenshots and submit for review. + +**Prerequisite:** Must be logged in via `lbp login --token lbpat_xxx` (PAT from Space profile page). + +## Reference: EventListener-Only Plugin Pattern + +For plugins that react to messages without commands or tools (e.g., auto-summarize URLs, collect messages, translate): + +``` +MyPlugin/ +├── manifest.yaml # Only EventListener in spec.components +├── main.py # BasePlugin with shared logic (fetch, LLM calls) +├── components/ +│ └── event_listener/ +│ ├── detector.yaml +│ └── detector.py +└── requirements.txt +``` + +**manifest.yaml** — only declare EventListener: +```yaml +spec: + components: + EventListener: + fromDirs: + - path: components/event_listener/ +``` + +**detector.py** — hook `*MessageReceived`, extract text, process, reply: +```python +@self.handler(events.PersonMessageReceived) +async def on_msg(event_context: context.EventContext): + event = event_context.event + text_parts = [] + for component in event.message_chain: + if isinstance(component, platform_message.Plain): + text_parts.append(component.text) + text = "".join(text_parts).strip() + + if should_handle(text): + event_context.prevent_default() + event_context.prevent_postorder() + result = await self.plugin.process(text) + await event_context.reply(platform_message.MessageChain([ + platform_message.Plain(text=result) + ])) +``` + +**Key:** Access shared plugin logic via `self.plugin` (the BasePlugin instance). diff --git a/skills/skills/langbot-plugin-dev/references/test-env-setup.md b/skills/skills/langbot-plugin-dev/references/test-env-setup.md new file mode 100644 index 000000000..c4fa20394 --- /dev/null +++ b/skills/skills/langbot-plugin-dev/references/test-env-setup.md @@ -0,0 +1,116 @@ +# Test Environment Setup + +## Docker Compose (GitOps) + +Create in `server-deploy` repo under `servers//langbot-test/docker-compose.yaml`: + +```yaml +version: "3" +services: + langbot_plugin_runtime: + image: rockchin/langbot:latest + container_name: langbot-test-runtime + volumes: + - /opt/docker-data/langbot-test/data/plugins:/app/data/plugins + ports: + - "5411:5401" + restart: on-failure + environment: + - TZ=Asia/Shanghai + command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "rt"] + networks: + - langbot_test_network + + langbot: + image: rockchin/langbot:latest + container_name: langbot-test + volumes: + - /opt/docker-data/langbot-test/data:/app/data + ports: + - "5310:5300" + restart: on-failure + depends_on: + - langbot_plugin_runtime + environment: + - TZ=Asia/Shanghai + networks: + - langbot_test_network + +networks: + langbot_test_network: + driver: bridge +``` + +## Post-Deploy Configuration + +After first start, LangBot auto-generates `data/config.yaml`. You need to update `plugin.runtime_ws_url` to match the runtime container name: + +```bash +# On the host, edit config +sed -i 's|ws://localhost:5400/control/ws|ws://langbot-test-runtime:5400/control/ws|' \ + /opt/docker-data/langbot-test/data/config.yaml +docker restart langbot-test +``` + +## Installing a Plugin + +Copy plugin directory to `data/plugins/` on the host: + +```bash +scp -r MyPlugin/ user@host:/opt/docker-data/langbot-test/data/plugins/MyPlugin/ +docker restart langbot-test-runtime # Runtime picks up new plugins on restart +``` + +## Caddy Reverse Proxy (Optional) + +If testing externally, add to Caddyfile on the same host: + +``` +langbot-test.example.com { + reverse_proxy langbot-test:5300 +} +``` + +Then reload: `docker exec caddy caddy reload --config /etc/caddy/Caddyfile` + +The WebSocket endpoint works through Caddy without special config. + +## WebSocket Test Script (Node.js) + +```javascript +const WebSocket = require('ws'); + +const PIPELINE_UUID = ''; +const BASE = 'wss://langbot-test.example.com'; +const URL = `${BASE}/api/v1/pipelines/${PIPELINE_UUID}/ws/connect?session_type=group`; + +const ws = new WebSocket(URL, { + headers: { Origin: BASE } +}); + +const send = (text) => { + ws.send(JSON.stringify({ + type: 'message', + message: [{ type: 'Plain', text }] + })); + console.log('[SENT]', text); +}; + +ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === 'connected') { + console.log('Connected!'); + // Send test messages + send('Message 1'); + setTimeout(() => send('Message 2'), 500); + setTimeout(() => send('!summary'), 2000); + } else if (msg.type === 'response' && msg.data?.is_final) { + console.log('[BOT]', msg.data.content); + } +}); + +ws.on('error', (e) => console.error('Error:', e.message)); +setTimeout(() => { ws.close(); process.exit(); }, 60000); +``` + +Requires: `npm install ws` diff --git a/skills/skills/langbot-skills-maintenance/SKILL.md b/skills/skills/langbot-skills-maintenance/SKILL.md new file mode 100644 index 000000000..4ba8fe692 --- /dev/null +++ b/skills/skills/langbot-skills-maintenance/SKILL.md @@ -0,0 +1,40 @@ +--- +name: langbot-skills-maintenance +description: Maintain the langbot-skills repository with low duplication. Use when adding, editing, or auditing LangBot skills, references, cases, troubleshooting entries, indexes, or periodic entropy-control checks for this skills repository. +--- + +# LangBot Skills Maintenance + +Use this skill before changing reusable assets in this repository. + +## Workflow + +1. Read `AGENTS.md`, `skills/.env`, and the relevant existing skill files. +2. Classify the change: + - `SKILL.md` for routing and concise operating rules. + - `references/*.md` for canonical detailed workflows. + - `cases/*.yaml` for executable test-plan skeletons. + - `suites/*.yaml` for reusable groups of case ids. + - `fixtures/fixtures.json` for deterministic fixture readiness metadata. + - `reports/evidence//automation-result.json` as automation output and `reports/evidence//result.json` as final judgment output; neither is a catalog asset to commit. + - `troubleshooting/*.yaml` for one reusable failure mode. +3. Search existing assets before adding new files: + - `rg "" skills` + - `bin/lbs case list` + - `bin/lbs suite list` + - `bin/lbs fixture list` +4. Put detail in one canonical place and link to it from cases or routing bullets. +5. Run the checks in `AGENTS.md` after edits. + +## Entropy Rules + +- Prefer extending an existing reference or troubleshooting entry when the root cause is the same. +- Keep cases short: setup, action, evidence, pass/fail checks. Do not paste long prompts or debug transcripts when a reference exists. +- Put machine-checkable inputs in `env`, `automation_env`, or fixtures; put operator-confirmed assumptions in `preconditions` so `test plan` can surface `manual_check`. +- Keep suites short: title, intent, tags, and ordered case ids. Do not duplicate case steps inside a suite. +- Keep fixture manifests factual: id, title, path, kind, and related case ids. Do not encode environment-specific absolute paths. +- Keep troubleshooting entries narrow: symptoms, patterns, likely causes, fixes, related assets. +- Do not hardcode local ports, browser profile paths, secrets, tokens, or provider keys. +- Use `bin/lbs index --check` to verify the committed index is current without writing it; run `bin/lbs index` when the index needs regeneration. + +For periodic repository audits, read `references/curation-workflow.md`. diff --git a/skills/skills/langbot-skills-maintenance/references/curation-workflow.md b/skills/skills/langbot-skills-maintenance/references/curation-workflow.md new file mode 100644 index 000000000..c40f25d1d --- /dev/null +++ b/skills/skills/langbot-skills-maintenance/references/curation-workflow.md @@ -0,0 +1,70 @@ +# Curation Workflow + +Use this checklist when the repository starts accumulating repeated cases, copied steps, or overlapping troubleshooting entries. + +## Audit Pass + +1. Inspect the current surface: + - `bin/lbs case list` + - `bin/lbs case list --json --priority p0 --automation` + - `bin/lbs case list --ready` + - `bin/lbs case list --machine-ready` + - `bin/lbs suite list` + - `bin/lbs fixture list` + - `rg "sandbox|provider|pipeline|plugin|knowledge|mcp" skills` + - `rg "If .* fails|Known Pitfalls|Debug Chat|/api/v1" skills` +2. Group nearby assets by intent, not by file path: + - user-facing scenario + - backend or provider dependency + - failure signature + - pass/fail evidence +3. Pick one canonical owner: + - stable procedures belong in `references/` + - deterministic files and packages belong in `fixtures/` plus `fixtures/fixtures.json` + - repeated failure signatures belong in `troubleshooting/` + - runnable QA paths belong in `cases/` + - reusable groups of QA paths belong in `suites/` + - skill entry points belong in `SKILL.md` + +## Merge Or Split + +Merge when two files share the same trigger, root cause, and fix. Keep the stronger id and move missing patterns into it. + +Split when a file mixes unrelated failure modes or requires different fixes. Each troubleshooting id should map to one diagnosis path. + +Move repeated step lists out of cases and into a reference when more than one case would need the same prompt, UI path, or log interpretation. + +Add or update a suite when developers repeatedly run the same ordered group of cases. Do not copy case steps into suites; use `bin/lbs suite plan ` to expand the group. +Use `bin/lbs suite start ` and `bin/lbs suite report --evidence-dir ` when validating that a suite is operational end to end. + +Add or update `fixtures/fixtures.json` when a case depends on a deterministic file, plugin package, or local test server. The manifest should use repo-relative paths under the owning skill and should not contain machine-local absolute paths. + +When adding Debug Chat Playwright automation, reuse `scripts/e2e/lib/debug-chat.mjs` for navigation, prompt send, response leaf matching, and known failure classification. Keep case-specific prompts and expected sentinels in case YAML automation fields when possible. + +## Case Review + +For every changed case: + +1. Ensure `steps` describe what to execute, not every command in the underlying implementation. +2. Ensure `checks` contain observable UI, log, network, or filesystem evidence. +3. Ensure `diagnostics` are fallback investigation hints, not pass criteria. +4. Ensure `priority`, `risk`, `ci_eligible`, and `evidence_required` match the actual repeatability and evidence burden. +5. Put must-have env vars in `env` / `automation_env`; put one-of choices such as URL-or-name in `env_any` / `automation_env_any`. +6. Ensure linked `skills` and `troubleshooting` ids exist. +7. Run: + + ```bash + bin/lbs validate + bin/lbs index --check + bin/lbs index + bin/lbs test plan + ``` + +## Final Gate + +Before handing off: + +- `git diff --stat` should show a focused change set. +- `skills.index.json` should be regenerated only by `bin/lbs index`. +- No new asset should contain local credentials, OAuth tokens, API keys, or copied localStorage values. +- The final note should say which checks ran and which cases or troubleshooting ids changed. diff --git a/skills/skills/langbot-space-ops/SKILL.md b/skills/skills/langbot-space-ops/SKILL.md new file mode 100644 index 000000000..196205a70 --- /dev/null +++ b/skills/skills/langbot-space-ops/SKILL.md @@ -0,0 +1,79 @@ +--- +name: langbot-space-ops +description: Browse and search the LangBot Space marketplaces (plugins, MCP servers, skills) through the Space MCP server. Use when an AI agent needs to discover LangBot extensions on space.langbot.app over MCP. Covers the /mcp endpoint, Personal Access Token (PAT) auth, the tool surface, and client configuration. Triggers on "langbot space mcp", "search langbot plugins", "langbot marketplace mcp", "space.langbot.app mcp". +--- + +# LangBot Space MCP Operations + +LangBot Space (space.langbot.app) exposes an **MCP server** so user-facing AI +agents can browse and search the marketplaces (plugins, MCP servers, skills). + +## Endpoint + +``` +https://space.langbot.app/mcp +``` + +Transport: **streamable HTTP** (stateless, JSON responses). For a self-hosted +Space instance: `http://:8383/mcp`. + +## Authentication + +Reuses the existing **Personal Access Token (PAT)** — the same token the `lbp` +CLI uses. Create one in your Space account (Profile → Personal Access Tokens), +then send it as a Bearer token: + +``` +Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`. + +## Client configuration + +```json +{ + "mcpServers": { + "langbot-space": { + "url": "https://space.langbot.app/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +## Tool surface + +| Tool | Purpose | +| --- | --- | +| `list_plugins` / `search_plugins` / `get_plugin` | Plugin marketplace | +| `list_mcp_servers` / `search_mcp_servers` / `get_mcp_server` | MCP-server marketplace | +| `list_skills` / `search_skills` / `get_skill` | Skill marketplace | + +`list_*` and `search_*` are paged (`page`, `page_size`). `get_*` takes +`author` + `name`. The tool surface mirrors the REST endpoints under +`/api/v1/marketplace/*` and is read/browse only. + +## How to use + +1. Create a PAT in your Space account settings. +2. Point your MCP client at `https://space.langbot.app/mcp` with the Bearer PAT. +3. Use `search_plugins` / `search_mcp_servers` / `search_skills` to find items, + then `get_*` for details (e.g. to obtain author/name for installation in + LangBot itself). + +## Implementation & maintenance (for Space developers) + +- Server: `internal/controller/mcp/server.go` (official Go MCP SDK + `github.com/modelcontextprotocol/go-sdk`). Tools call the service layer + (`PluginService`, `MCPService`, `SkillService`) directly. +- Mount: `internal/controller/api.go` at `/mcp` and `/mcp/*any`. +- Auth: PAT via `AccountService.ValidatePersonalAccessToken`. +- Docs: `docs/MCP_SERVER.md`. + +> When you add, remove, or change a marketplace API endpoint that should be +> agent-accessible, update the corresponding MCP tool **and** this skill. The +> MCP tool surface and the API must stay aligned (see `AGENTS.md`). + +## Pitfalls + +- The PAT prefix is `lbpat_` (Space), distinct from LangBot's `lbk_` API keys. +- This server is read/browse only; it does not publish or modify marketplace + items. Use the web UI or REST API (with appropriate auth) for that. diff --git a/skills/skills/langbot-testing/SKILL.md b/skills/skills/langbot-testing/SKILL.md new file mode 100644 index 000000000..748ae9b81 --- /dev/null +++ b/skills/skills/langbot-testing/SKILL.md @@ -0,0 +1,44 @@ +--- +name: langbot-testing +description: Test LangBot WebUI and core product flows with an automated browser and backend logs. Use when validating the configured LangBot frontend, pipeline Debug Chat, model provider setup and test buttons, bot and knowledge-base UI flows, or troubleshooting failed LangBot end-to-end tests. +--- + +# LangBot Testing + +Use this skill when an agent needs to verify LangBot behavior through the WebUI instead of only reading code. + +## Routing + +- **General WebUI testing**: read `references/web-ui-testing.md`. +- **Pipeline Debug Chat**: read `references/pipeline-debug-chat.md`. +- **Dify AgentRunner**: read `references/dify-agent-runner.md`. +- **Model provider setup or test button**: read `references/model-provider-testing.md`. +- **Plugin install/runtime/tool/page smoke**: read `references/plugin-e2e-smoke.md`. +- **Local Agent Runner**: read `references/local-agent-runner.md`. +- **Local Agent Runner path coverage**: read `references/local-agent-runner-coverage.md`. +- **Diff-aware AgentRunner QA after code changes**: read `references/agent-runner-qa-workflow.md`. +- **Agent Runner release gate**: read `references/agent-runner-release-gate.md`. +- **Sandbox-backed skill authoring**: read `references/sandbox-skill-authoring.md`. +- **LangRAG knowledge bases**: read `references/langrag-knowledge-base.md`. +- **MCP stdio tool testing**: read `references/mcp-stdio-testing.md`. +- **Performance, reliability, or chaos probes**: read `references/performance-reliability-testing.md`. +- **Drive a live instance over MCP (not raw HTTP)**: use the `langbot-mcp-ops` skill — the instance exposes an MCP server at `http://:5300/mcp` (reuses API keys). Useful for setting up bots/pipelines/models as test fixtures programmatically. +- **Known failures and fixes**: read `references/troubleshooting.md`. +- **Reusable test groups**: run `bin/lbs suite list` and `bin/lbs suite plan ` before manually assembling a case set. + +## Rules + +- Read `../.env` first and use `LANGBOT_FRONTEND_URL` and `LANGBOT_BACKEND_URL` instead of hardcoded ports. +- If a standalone frontend dev server is running, `LANGBOT_FRONTEND_URL` may point to `LANGBOT_DEV_FRONTEND_URL`; otherwise it may point to the backend WebUI. +- Confirm the backend and frontend are actually running before testing. +- Run `bin/lbs fixture check` before fixture-heavy MCP, RAG, multimodal, or plugin smoke tests. +- For runner externalization release checks, run `bin/lbs test run agent-runner-release-preflight` before the full `agent-runner-release-gate` suite so configuration blockers are separated from product failures. +- Read `Manual Readiness` in `bin/lbs test plan `; `manual_check` means the declared preconditions or setup still need operator confirmation for this run. +- Use an authenticated browser profile prepared by `langbot-env-setup`. +- Do not expose API keys, OAuth secrets, tokens, or localStorage token values in output. +- A WebUI test is not complete until the visible UI result is checked against backend logs or network behavior. +- A performance result is not complete without `metrics` evidence and a clear split between LangBot overhead and external provider/tool/network time. +- A chaos or reliability result is not complete until the fault scope, cleanup, and recovery checks are recorded. +- For a suite, use `bin/lbs suite start ` to create the suite evidence root, per-case directories, and `suite-start.json`/`suite-start.md` handoff files; use `bin/lbs test result ` to write final per-case `result.json`, then run `bin/lbs suite report --evidence-dir `. +- Do not mark a case `pass` until `test result --evidence` covers every value in the case's `evidence_required`. +- For runner-specific Debug Chat cases, use the case-specific pipeline env declared by `automation_pipeline_url_env` / `automation_pipeline_name_env`; do not silently reuse a generic `LANGBOT_PIPELINE_URL`. diff --git a/skills/skills/langbot-testing/cases/acp-agent-runner-debug-chat.yaml b/skills/skills/langbot-testing/cases/acp-agent-runner-debug-chat.yaml new file mode 100644 index 000000000..ae10cea66 --- /dev/null +++ b/skills/skills/langbot-testing/cases/acp-agent-runner-debug-chat.yaml @@ -0,0 +1,79 @@ +id: acp-agent-runner-debug-chat +title: "ACP AgentRunner can answer through Debug Chat using real remote Claude" +mode: agent-browser +area: pipeline +type: regression +priority: p2 +risk: high +ci_eligible: false +tags: + - agent-runner + - acp + - claude + - external-runner + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +env_any: + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL|LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME + - LANGBOT_E2E_PROMPT + - LANGBOT_E2E_EXPECTED_TEXT + - LANGBOT_E2E_EXPECTED_RUNNER_ID + - LANGBOT_E2E_RESPONSE_TIMEOUT_MS +automation_pipeline_url_env: LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot/acp-agent-runner/default" +automation_prompt: "Use the injected LangBot MCP server tool langbot_get_current_event once. If the MCP call succeeds, reply with exactly ACP_AGENT_RUNNER_E2E_OK." +automation_expected_text: "ACP_AGENT_RUNNER_E2E_OK" +automation_response_timeout_ms: "300000" +setup_automation: + - "node:scripts/e2e/ensure-acp-agent-runner-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME +preconditions: + - "The remote machine has a working Claude Code login and can run npx -y @agentclientprotocol/claude-agent-acp." + - "LangBot can non-interactively SSH to the remote machine; the runner opens the MCP reverse tunnel automatically." +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Open the ACP AgentRunner QA pipeline." + - "Confirm the pipeline AI runner is plugin:langbot/acp-agent-runner/default." + - "Open Debug Chat." + - "Ask the real remote Claude ACP agent to call langbot_get_current_event and return ACP_AGENT_RUNNER_E2E_OK exactly." +checks: + - "UI: Debug Chat shows the user prompt." + - "UI: Debug Chat shows a Bot response containing ACP_AGENT_RUNNER_E2E_OK." + - "Logs: Backend logs include Processing request from person_websocket and Streaming completed for this run." + - "Logs: No acp runner request error appears for this run." + - "Console: No unexpected frontend errors appear during Debug Chat." +evidence_required: + - ui + - console + - backend_log +diagnostics: + - "Use scripts/e2e/ensure-acp-agent-runner-pipeline.mjs --write-env to create/update the pipeline." + - "For remote Claude on 101, verify ssh yhh@101.34.71.12 can run without password prompts; no separate ssh -R process is required." +success_patterns: + - "ACP_AGENT_RUNNER_E2E_OK" + - "Processing request from person_websocket" + - "Streaming completed" +failure_patterns: + - "acp.command_not_found" + - "acp.process_exited" + - "Agent runner plugin:langbot/acp-agent-runner/default execution failed" +troubleshooting: + - backend-not-listening + - plugin-runtime-timeout + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/agent-runner-async-db-readiness.yaml b/skills/skills/langbot-testing/cases/agent-runner-async-db-readiness.yaml new file mode 100644 index 000000000..3324a1c8e --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-async-db-readiness.yaml @@ -0,0 +1,34 @@ +id: agent-runner-async-db-readiness +title: "AgentRunner async DB readiness probe" +mode: probe +area: release +type: smoke +priority: p0 +risk: high +ci_eligible: true +tags: + - agent-runner + - probe + - async-db + - aiosqlite +skills: + - langbot-testing +env: +automation: skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs +steps: + - "Run `rtk bin/lbs test run agent-runner-async-db-readiness --dry-run` first; remove `--dry-run` after checking the planned evidence directory." + - "Automation checks whether a direct aiosqlite in-memory connection can create a table within the readiness timeout." +checks: + - "automation-result.json status is pass or env_issue." + - "pass means async SQLite tests are worth running." + - "env_issue means async SQLite-dependent Host pytest probes should be classified as environment-limited until fixed." +evidence_required: + - filesystem +diagnostics: + - "If this probe returns env_issue, run agent-runner-ledger-invariants for fast ledger coverage and skip async ledger pytest as a release blocker in this environment." +success_patterns: + - "AIOSQLITE_READY" +failure_patterns: + - "aiosqlite readiness timed out" +troubleshooting: + - aiosqlite-connect-hangs diff --git a/skills/skills/langbot-testing/cases/agent-runner-behavior-matrix.yaml b/skills/skills/langbot-testing/cases/agent-runner-behavior-matrix.yaml new file mode 100644 index 000000000..944bca68a --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-behavior-matrix.yaml @@ -0,0 +1,34 @@ +id: agent-runner-behavior-matrix +title: "AgentRunner deterministic behavior matrix probe" +mode: probe +area: release +type: regression +priority: p0 +risk: high +ci_eligible: true +tags: + - agent-runner + - probe + - deterministic-runner + - protocol +skills: + - langbot-testing +env: +automation: skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs +steps: + - "Run `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run` first; remove `--dry-run` after checking the planned evidence directory." + - "Automation reads fixtures/agent-runner/qa-runner-behaviors.json and validates each result sequence through the Host AgentResultNormalizer." +checks: + - "automation-result.json status is pass." + - "probe-stdout.log contains QA_RUNNER_BEHAVIOR_MATRIX_OK." + - "The matrix covers ok, stream_ok, empty_output, malformed_result, and controlled_failure." +evidence_required: + - filesystem +diagnostics: + - "fail means the deterministic behavior fixture and Host result normalization disagree." +success_patterns: + - "QA_RUNNER_BEHAVIOR_MATRIX_OK" +failure_patterns: + - "AssertionError" + - "RunnerProtocolError" + - "behavior matrix exited" diff --git a/skills/skills/langbot-testing/cases/agent-runner-fixture-contract.yaml b/skills/skills/langbot-testing/cases/agent-runner-fixture-contract.yaml new file mode 100644 index 000000000..d8f0e04bc --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-fixture-contract.yaml @@ -0,0 +1,35 @@ +id: agent-runner-fixture-contract +title: "QA AgentRunner fixture contract probe" +mode: probe +area: release +type: regression +priority: p0 +risk: high +ci_eligible: true +tags: + - agent-runner + - probe + - fixture + - deterministic-runner +skills: + - langbot-testing +env: +automation: skills/langbot-testing/probes/agent-runner-fixture-contract.mjs +steps: + - "Run `rtk bin/lbs test run agent-runner-fixture-contract --dry-run` first; remove `--dry-run` after checking the planned evidence directory." + - "Automation imports the QA AgentRunner fixture source and executes normal, streaming, and controlled-failure paths with SDK entities." +checks: + - "automation-result.json status is pass." + - "probe-stdout.log contains QA_AGENT_RUNNER_FIXTURE_CONTRACT_OK." + - "Normal input returns QA_AGENT_RUNNER_OK:." + - "Streaming input emits message.delta chunks and completes." + - "Failure input returns QA_AGENT_RUNNER_CONTROLLED_FAILURE." +evidence_required: + - filesystem +diagnostics: + - "This validates the deterministic fixture source contract. It does not prove the plugin package is installed in a live LangBot instance." +success_patterns: + - "QA_AGENT_RUNNER_FIXTURE_CONTRACT_OK" +failure_patterns: + - "AssertionError" + - "fixture contract exited" diff --git a/skills/skills/langbot-testing/cases/agent-runner-ledger-concurrency.yaml b/skills/skills/langbot-testing/cases/agent-runner-ledger-concurrency.yaml new file mode 100644 index 000000000..3b57c5435 --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-ledger-concurrency.yaml @@ -0,0 +1,41 @@ +id: agent-runner-ledger-concurrency +title: "AgentRunner run ledger concurrency and auth pytest probe" +mode: probe +area: release +type: regression +priority: p0 +risk: high +ci_eligible: true +tags: + - agent-runner + - probe + - pytest + - run-ledger + - concurrency +skills: + - langbot-testing +env: +automation: skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs +preconditions: + - "This Host pytest probe can be slow in the current multi-repo dev environment; keep it in the release gate, but do not treat a timeout as a browser E2E failure without checking pytest logs." +steps: + - "Run `rtk bin/lbs test run agent-runner-ledger-concurrency --dry-run` first; remove `--dry-run` only after `agent-runner-async-db-readiness` is pass." + - "Automation resolves LANGBOT_REPO, defaulting to ../LangBot when the env var is unset." + - "Automation runs selected high-value tests from test_run_ledger_store.py and test_run_ledger_api_auth.py." +checks: + - "automation-result.json status is pass." + - "pytest exit status is 0 for selected run ledger claim, lease, status, token, ownership, and active-claim tests." + - "pytest-stdout.log and pytest-stderr.log are written under LBS_EVIDENCE_DIR." +evidence_required: + - filesystem +diagnostics: + - "env_issue means LANGBOT_REPO/default ../LangBot did not resolve, rtk/uv was unavailable, or the expected test files are missing." + - "fail means one of the selected LangBot run ledger pytest targets failed or timed out." +success_patterns: + - "pytest passed" +failure_patterns: + - "pytest exited with status" + - "pytest timed out" + - "Failed to start pytest command" +troubleshooting: + - aiosqlite-connect-hangs diff --git a/skills/skills/langbot-testing/cases/agent-runner-ledger-contention.yaml b/skills/skills/langbot-testing/cases/agent-runner-ledger-contention.yaml new file mode 100644 index 000000000..e90abd8b7 --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-ledger-contention.yaml @@ -0,0 +1,34 @@ +id: agent-runner-ledger-contention +title: "AgentRunner ledger SQLite contention probe" +mode: probe +area: release +type: regression +priority: p1 +risk: high +ci_eligible: true +tags: + - agent-runner + - probe + - stress + - ledger + - concurrency +skills: + - langbot-testing +env: +automation: skills/langbot-testing/probes/agent-runner-ledger-contention.mjs +steps: + - "Run `rtk bin/lbs test run agent-runner-ledger-contention --dry-run` first; remove `--dry-run` after checking the planned evidence directory." + - "Automation creates 120 queued runs in a file-backed SQLite database and uses eight worker threads to claim runs under write contention." +checks: + - "automation-result.json status is pass." + - "probe-stdout.log contains LEDGER_CONTENTION_OK." + - "Every run is claimed once, reaches completed status, and has dispatch_attempts = 1." +evidence_required: + - filesystem +diagnostics: + - "This probe catches obvious exactly-once claim regressions under local SQLite contention; it does not replace async Host pytest or PostgreSQL concurrency checks." +success_patterns: + - "LEDGER_CONTENTION_OK" +failure_patterns: + - "AssertionError" + - "ledger contention exited" diff --git a/skills/skills/langbot-testing/cases/agent-runner-ledger-invariants.yaml b/skills/skills/langbot-testing/cases/agent-runner-ledger-invariants.yaml new file mode 100644 index 000000000..578cd8a31 --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-ledger-invariants.yaml @@ -0,0 +1,35 @@ +id: agent-runner-ledger-invariants +title: "AgentRunner ledger schema and status invariants probe" +mode: probe +area: release +type: regression +priority: p0 +risk: high +ci_eligible: true +tags: + - agent-runner + - probe + - ledger + - invariant +skills: + - langbot-testing +env: +automation: skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs +steps: + - "Run `rtk bin/lbs test run agent-runner-ledger-invariants --dry-run` first; remove `--dry-run` after checking the planned evidence directory." + - "Automation resolves LANGBOT_REPO, defaulting to ../LangBot, and imports the sibling SDK from LANGBOT_PLUGIN_SDK_REPO or ../langbot-plugin-sdk/src." + - "Automation checks run status sets, terminal status validation, ledger table/index DDL, and a minimal synchronous insert/read path." +checks: + - "automation-result.json status is pass." + - "probe-stdout.log contains LEDGER_INVARIANTS_OK." + - "The probe does not require aiosqlite or browser UI." +evidence_required: + - filesystem +diagnostics: + - "env_issue means LANGBOT_REPO/default ../LangBot did not resolve or Python dependencies are unavailable." + - "fail means a ledger schema/status invariant changed and the release gate needs review." +success_patterns: + - "LEDGER_INVARIANTS_OK" +failure_patterns: + - "AssertionError" + - "ledger invariant probe exited" diff --git a/skills/skills/langbot-testing/cases/agent-runner-ledger-stress.yaml b/skills/skills/langbot-testing/cases/agent-runner-ledger-stress.yaml new file mode 100644 index 000000000..acc527943 --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-ledger-stress.yaml @@ -0,0 +1,33 @@ +id: agent-runner-ledger-stress +title: "AgentRunner ledger lightweight stress probe" +mode: probe +area: release +type: regression +priority: p1 +risk: high +ci_eligible: true +tags: + - agent-runner + - probe + - stress + - ledger +skills: + - langbot-testing +env: +automation: skills/langbot-testing/probes/agent-runner-ledger-stress.mjs +steps: + - "Run `rtk bin/lbs test run agent-runner-ledger-stress --dry-run` first; remove `--dry-run` after checking the planned evidence directory." + - "Automation creates 100 queued runs in synchronous SQLite and simulates five runtimes claiming them in priority order." +checks: + - "automation-result.json status is pass." + - "probe-stdout.log contains LEDGER_STRESS_OK." + - "Every run is claimed once and reaches a terminal completed status." +evidence_required: + - filesystem +diagnostics: + - "This probe is a fast deterministic stress baseline; it does not replace PostgreSQL/async concurrency tests." +success_patterns: + - "LEDGER_STRESS_OK" +failure_patterns: + - "AssertionError" + - "ledger stress exited" diff --git a/skills/skills/langbot-testing/cases/agent-runner-live-install.yaml b/skills/skills/langbot-testing/cases/agent-runner-live-install.yaml new file mode 100644 index 000000000..f1bcaaf72 --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-live-install.yaml @@ -0,0 +1,46 @@ +id: agent-runner-live-install +title: "QA AgentRunner package installs and registers in LangBot" +mode: probe +area: release +type: regression +priority: p0 +risk: high +ci_eligible: false +tags: + - agent-runner + - plugin + - local-install + - fixture +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_BACKEND_URL + - LANGBOT_REPO + - LANGBOT_E2E_LOGIN_USER +automation: scripts/e2e/install-qa-plugin-smoke.mjs +automation_plugin_package: "skills/langbot-testing/fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg" +automation_expected_plugin_id: "qa/agent-runner" +automation_expected_tool: "" +automation_expected_runner_id: "plugin:qa/agent-runner/default" +steps: + - "Run `rtk bin/lbs test run agent-runner-live-install --dry-run` first; remove `--dry-run` only after readiness points at a local test LangBot instance." + - "Automation authenticates the local test user, uploads the QA AgentRunner .lbpkg package, waits for the install task, and reads pipeline metadata." +checks: + - "automation-result.json status is pass." + - "/api/v1/plugins lists qa/agent-runner after install." + - "/api/v1/pipelines/_/metadata lists plugin:qa/agent-runner/default as an available runner." +evidence_required: + - api_diagnostic + - filesystem +diagnostics: + - "This proves the deterministic package installs and registers a runner. It does not prove Debug Chat execution; use a later browser case for that." + - "If installation fails during dependencies, inspect plugin runtime logs and plugin-dependency-install-offline." +success_patterns: + - "qa/agent-runner is installed." +failure_patterns: + - "Plugin install task did not complete successfully" + - "plugin:qa/agent-runner/default is not listed" +troubleshooting: + - plugin-runtime-timeout + - plugin-dependency-install-offline diff --git a/skills/skills/langbot-testing/cases/agent-runner-qa-debug-chat.yaml b/skills/skills/langbot-testing/cases/agent-runner-qa-debug-chat.yaml new file mode 100644 index 000000000..1d86b5ec7 --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-qa-debug-chat.yaml @@ -0,0 +1,70 @@ +id: agent-runner-qa-debug-chat +title: "QA AgentRunner returns deterministic output through Debug Chat" +mode: agent-browser +area: pipeline +type: regression +priority: p0 +risk: high +ci_eligible: false +tags: + - agent-runner + - pipeline + - debug-chat + - fixture +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL + - LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL + - LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME +automation_expected_runner_id: "plugin:qa/agent-runner/default" +automation_prompt: "hello-live" +automation_expected_text: "QA_AGENT_RUNNER_OK:hello-live" +automation_response_timeout_ms: "120000" +automation_reset_debug_chat: "1" +setup_automation: + - "case:agent-runner-live-install" + - "node:scripts/e2e/ensure-qa-agent-runner-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL + - LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Open the pipeline from LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL or LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME." + - "Confirm the pipeline AI runner is plugin:qa/agent-runner/default." + - "Open Debug Chat." + - "Send: hello-live." +checks: + - "UI: The user message appears in Debug Chat." + - "UI: A Bot message appears and contains QA_AGENT_RUNNER_OK:hello-live." + - "API diagnostic: pipeline config uses plugin:qa/agent-runner/default." + - "Console: No unexpected frontend runtime errors appear during the send/receive path." +evidence_required: + - ui + - screenshot + - console + - network + - api_diagnostic +diagnostics: + - "This is the deterministic live execution proof that sits after fixture contract and live install." + - "If the runner id mismatch is reported, rerun ensure-qa-agent-runner-pipeline.mjs --write-env." +success_patterns: + - "QA_AGENT_RUNNER_OK:hello-live" +failure_patterns: + - "plugin:qa/agent-runner/default execution failed" + - "Action invoke_llm_stream call timed out" + - "Agent runner temporarily unavailable" +troubleshooting: + - plugin-runtime-timeout diff --git a/skills/skills/langbot-testing/cases/agent-runner-release-preflight.yaml b/skills/skills/langbot-testing/cases/agent-runner-release-preflight.yaml new file mode 100644 index 000000000..b70000591 --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-release-preflight.yaml @@ -0,0 +1,74 @@ +id: agent-runner-release-preflight +title: "Agent runner release gate preflight validates environment readiness" +mode: agent-browser +area: release +type: smoke +priority: p0 +risk: high +ci_eligible: false +tags: + - agent-runner + - release-gate + - preflight + - environment +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +env_any: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL|LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL|LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME +automation: scripts/e2e/agent-runner-release-preflight.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE +automation_env_any: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL|LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL|LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME +preconditions: + - "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent release pipeline." + - "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL or LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME points to the ACP AgentRunner release pipeline." + - "The active browser profile is authenticated for the same LangBot backend." + - "By default the preflight performs a cheap model test for the local-agent primary model; set LANGBOT_PREFLIGHT_TEST_MODELS=0 only when deliberately classifying model credentials outside this run." +steps: + - "Open LANGBOT_FRONTEND_URL with the configured browser profile." + - "Use the browser token to call LangBot backend readiness APIs without printing token values." + - "Check plugin runtime status, Box status, required runner plugins, qa-plugin-smoke, and qa_plugin_echo." + - "Resolve the local-agent and ACP AgentRunner QA pipelines from their case-specific env vars." + - "Assert each pipeline uses the expected runner id." + - "Assert the external runner pipeline uses the expected runner id." + - "Assert the local-agent primary model advertises func_call and vision for the full release gate." + - "Run the local-agent primary model test endpoint unless LANGBOT_PREFLIGHT_TEST_MODELS=0." +checks: + - "API diagnostic: api-diagnostic.json has no blockers and no env_issues." + - "API diagnostic: required pipelines resolve to plugin:langbot/local-agent/default and plugin:langbot/acp-agent-runner/default." + - "API diagnostic: qa_plugin_echo is exposed by /api/v1/tools." + - "API diagnostic: local-agent model check catches invalid credentials or missing func_call/vision before release E2E starts." + - "Secret safety: token values, api keys, and provider secrets are not printed." +evidence_required: + - ui + - screenshot + - console + - network + - api_diagnostic +diagnostics: + - "blocked means the test instance is not configured for the full release gate: missing pipeline, wrong runner id, or missing plugin." + - "env_issue means the runtime or upstream dependency is not usable: backend unavailable, plugin runtime down, Box down, or the local-agent model cannot pass a model test." + - "If qa_mcp_echo is absent here, continue to mcp-stdio-register before mcp-stdio-tool-call; qa_mcp_echo is not required before registration." + - "If the model check fails with invalid api key, switch the local-agent release pipeline to a known-good func_call model before diagnosing runner code." +success_patterns: + - "Release gate preflight passed" +failure_patterns: + - "Preflight blocked" + - "Preflight environment issue" + - "invalid api key" + - "runner.llm_error" +troubleshooting: + - backend-not-listening + - plugin-runtime-timeout + - local-agent-model-route-unavailable + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/agent-runner-runtime-chaos.yaml b/skills/skills/langbot-testing/cases/agent-runner-runtime-chaos.yaml new file mode 100644 index 000000000..d7aeca392 --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-runtime-chaos.yaml @@ -0,0 +1,38 @@ +id: agent-runner-runtime-chaos +title: "AgentRunner SDK runtime chaos pytest probe" +mode: probe +area: release +type: regression +priority: p0 +risk: high +ci_eligible: true +tags: + - agent-runner + - probe + - pytest + - runtime + - sdk +skills: + - langbot-testing +env: +automation: skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs +steps: + - "Run `rtk bin/lbs test run agent-runner-runtime-chaos --dry-run` first; remove `--dry-run` after checking the SDK repo target and evidence directory." + - "Automation resolves LANGBOT_PLUGIN_SDK_REPO, defaulting to ../langbot-plugin-sdk when the env var is unset." + - "Automation runs the existing SDK pytest files tests/runtime/plugin/test_mgr_agent_runner.py and tests/runtime/test_pull_api_handlers.py." +checks: + - "automation-result.json status is pass." + - "pytest exit status is 0 for the existing AgentRunner runtime and pull API handler tests." + - "pytest-stdout.log and pytest-stderr.log are written under LBS_EVIDENCE_DIR." +evidence_required: + - filesystem +diagnostics: + - "This probe does not open the WebUI; it runs SDK pytest targets directly." + - "env_issue means LANGBOT_PLUGIN_SDK_REPO/default ../langbot-plugin-sdk did not resolve, rtk/uv was unavailable, or the expected test files are missing." + - "fail means the existing SDK runtime pytest target failed or timed out." +success_patterns: + - "pytest passed" +failure_patterns: + - "pytest exited with status" + - "pytest timed out" + - "Failed to start pytest command" diff --git a/skills/skills/langbot-testing/cases/dify-agent-debug-chat.yaml b/skills/skills/langbot-testing/cases/dify-agent-debug-chat.yaml new file mode 100644 index 000000000..42b0f62bf --- /dev/null +++ b/skills/skills/langbot-testing/cases/dify-agent-debug-chat.yaml @@ -0,0 +1,51 @@ +id: dify-agent-debug-chat +title: "Dify AgentRunner returns a response through Pipeline Debug Chat" +mode: agent-browser +area: pipeline +type: provider +priority: p2 +risk: medium +ci_eligible: false +tags: + - dify + - agent-runner + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +preconditions: + - "A Dify app Service API key is available from the active secret source and must not be printed in reports." + - "The target pipeline is safe to modify for Dify runner configuration." +steps: + - "Ensure a Dify app Service API key is available from the active secret source." + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Pipelines and open the target pipeline." + - "Open Configuration > AI." + - "Select runner Dify." + - "Set Base URL, App Type, API Key, Base Prompt, and Timeout according to references/dify-agent-runner.md." + - "Save the pipeline." + - "Open Debug Chat." + - "Send a prompt asking the bot to reply exactly with a unique sentinel, for example LANGBOT_DIFY_OK_." +checks: + - "UI: The runner selector and runner config identify Dify, not a generic Default label." + - "UI: Debug Chat shows a Bot message containing the sentinel." + - "History/log validation: The sentinel is present in an assistant/bot message, not only in the echoed User message." + - "Logs: Backend logs show Dify /chat-messages returned HTTP 200 and Conversation(0) Streaming completed." + - "Console: No unexpected frontend errors appear during runner configuration or Debug Chat." + - "Secret safety: No Dify API key, JWT, or browser token is printed in reports." +evidence_required: + - ui + - console + - backend_log +diagnostics: + - "Use direct Dify streaming API only to distinguish invalid Dify credentials from LangBot runner failures." + - "If direct Dify blocking mode fails for an Agent Chat app, retry streaming before treating credentials as invalid." + - "Use GET /api/v1/pipelines/{uuid} only to confirm saved runner_config." +troubleshooting: + - agent-runner-actor-context-fields + - ambiguous-runner-default-label + - plugin-runtime-timeout + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-cross-pipeline-isolation.yaml b/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-cross-pipeline-isolation.yaml new file mode 100644 index 000000000..9e8e09af0 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-cross-pipeline-isolation.yaml @@ -0,0 +1,84 @@ +id: langbot-fake-provider-debug-chat-cross-pipeline-isolation +title: "LangBot Debug Chat fake-provider cross-pipeline isolation probe" +mode: probe +area: reliability +type: reliability +priority: p1 +risk: high +ci_eligible: false +tags: + - reliability + - debug-chat + - websocket + - fake-provider + - isolation + - concurrency + - metrics +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_BACKEND_URL + - LANGBOT_FRONTEND_URL + - LANGBOT_E2E_LOGIN_USER +automation: skills/langbot-testing/probes/langbot-debug-chat-cross-pipeline-isolation.mjs +automation_env: + - LANGBOT_BACKEND_URL + - LANGBOT_E2E_LOGIN_USER + - LANGBOT_FAKE_PROVIDER_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_A_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_A_NAME + - LANGBOT_FAKE_PROVIDER_PIPELINE_B_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_B_NAME +automation_debug_chat_load_requests: "6" +automation_debug_chat_load_concurrency: "4" +automation_debug_chat_load_timeout_ms: "30000" +automation_debug_chat_load_response_p95_ms: "5000" +automation_debug_chat_load_max_error_rate: "0" +automation_debug_chat_load_prompt_template: '请只回复 "{expected}",不要解释,不要添加其他字符。' +automation_debug_chat_load_stream: "true" +automation_debug_chat_load_reset: "true" +metrics_thresholds_json: '{"cross_pipeline_leak_count":{"max":0},"response_p95_ms":{"max":5000},"error_rate":{"max":0}}' +load_profile_json: '{"requests_per_pipeline":6,"pipelines":2,"concurrency":4,"path":"Pipeline Debug Chat WebSocket","provider":"controlled fake OpenAI-compatible provider","metric":"cross-pipeline response isolation and send-to-final-assistant-response"}' +setup_automation: + - "node:scripts/e2e/ensure-fake-provider-cross-pipelines.mjs --write-env" +setup_provides_env: + - LANGBOT_FAKE_PROVIDER_URL + - LANGBOT_FAKE_PROVIDER_BASE_URL + - LANGBOT_FAKE_PROVIDER_PID + - LANGBOT_FAKE_PROVIDER_PIPELINE_A_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_A_NAME + - LANGBOT_FAKE_PROVIDER_PIPELINE_B_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_B_NAME +steps: + - "Start or reuse the local fake OpenAI-compatible provider." + - "Create or update two local-agent pipelines that both point at the controlled fake provider." + - "Reset both Debug Chat sessions and the fake-provider request log." + - "Open concurrent WebSocket Debug Chat connections to both pipelines and send unique pipeline-scoped response tokens." +checks: + - "automation-result.json status is pass only when every request receives its own expected token and cross_pipeline_leak_count is zero." + - "metrics_summary includes by_pipeline status counts, fake-provider request count, and LangBot/provider timing estimates." + - "samples.json contains per-request pipeline labels so any leak can be attributed to the receiving pipeline." +evidence_required: + - metrics + - network + - api_diagnostic + - filesystem +diagnostics: + - "This probe targets Debug Chat isolation under concurrent traffic from two pipelines." + - "It is designed to expose regressions where global pipeline state causes one pipeline's assistant response to be delivered to another pipeline's Debug Chat session." + - "Same-pipeline foreign responses are tolerated because Debug Chat intentionally broadcasts within the same pipeline/session; cross-pipeline tokens are never tolerated." + - "Known product bug: current releases may fail this probe because Debug Chat replies can read singleton WebSocket proxy pipeline state after another pipeline overwrites it. See https://github.com/langbot-app/LangBot/issues/2286." +expected_failures: + - "https://github.com/langbot-app/LangBot/issues/2286" +success_patterns: + - "Debug Chat cross-pipeline isolation probe passed" +failure_patterns: + - "cross_pipeline_leak" + - "Timed out after" + - "WebSocket connection error" + - "Final assistant response did not include" +troubleshooting: + - backend-not-listening + - debug-chat-history-contaminates-automation + - local-agent-model-route-unavailable diff --git a/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-fault-recovery.yaml b/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-fault-recovery.yaml new file mode 100644 index 000000000..7dfa45c91 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-fault-recovery.yaml @@ -0,0 +1,95 @@ +id: langbot-fake-provider-debug-chat-fault-recovery +title: "LangBot Debug Chat fake-provider fault recovery probe" +mode: probe +area: reliability +type: chaos +priority: p1 +risk: high +ci_eligible: false +tags: + - reliability + - chaos + - debug-chat + - websocket + - fake-provider + - fault-injection + - metrics +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_BACKEND_URL + - LANGBOT_FRONTEND_URL + - LANGBOT_E2E_LOGIN_USER +automation: skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs +automation_env: + - LANGBOT_BACKEND_URL + - LANGBOT_E2E_LOGIN_USER + - LANGBOT_FAKE_PROVIDER_PIPELINE_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_FAKE_PROVIDER_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +automation_debug_chat_load_requests: "6" +automation_debug_chat_load_concurrency: "1" +automation_debug_chat_load_timeout_ms: "15000" +automation_debug_chat_load_response_p95_ms: "5000" +automation_debug_chat_load_max_error_rate: "0" +automation_debug_chat_load_min_ok_count: "6" +automation_debug_chat_load_min_provider_fault_count: "2" +automation_debug_chat_load_expected_prefix: "FAULTQA" +automation_debug_chat_load_prompt_template: '请只回复 "{expected}",不要解释,不要添加其他字符。' +automation_debug_chat_load_stream: "true" +automation_debug_chat_load_reset: "true" +automation_debug_chat_load_fail_on_final_mismatch: "true" +automation_fake_provider_first_token_delay_ms: "25" +automation_fake_provider_chunk_delay_ms: "10" +automation_fake_provider_chunk_count: "0" +automation_fake_provider_fail_first_n: "2" +automation_fake_provider_fail_every_n: "0" +automation_fake_provider_fault_status: "503" +metrics_thresholds_json: '{"response_p95_ms":{"max":5000},"error_rate":{"max":0},"ok_count_min":{"min":6},"fake_provider_fault_count_min":{"min":2}}' +fault_model_json: '{"provider_fault":"HTTP 503 for first 2 fake-provider chat completions after reset","expected_behavior":"LangBot retries or otherwise recovers from bounded provider failures so every Debug Chat request receives its expected response without backend crash."}' +load_profile_json: '{"requests":6,"concurrency":1,"path":"Pipeline Debug Chat WebSocket","provider":"controlled fake OpenAI-compatible provider","classification":"fault-recovery-not-throughput-benchmark"}' +setup_automation: + - "node:scripts/e2e/ensure-fake-provider-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_FAKE_PROVIDER_URL + - LANGBOT_FAKE_PROVIDER_BASE_URL + - LANGBOT_FAKE_PROVIDER_PID + - LANGBOT_FAKE_PROVIDER_PROVIDER_UUID + - LANGBOT_FAKE_PROVIDER_MODEL_UUID + - LANGBOT_FAKE_PROVIDER_PIPELINE_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +steps: + - "Configure the local fake provider to return HTTP 503 for the first two chat completions after reset." + - "Create or update the LangBot provider, model, and local-agent pipeline that points at the fake provider." + - "Reset the target Debug Chat session and fake-provider request counter." + - "Send a sequential Debug Chat batch and verify later requests recover after the injected provider faults." +checks: + - "automation-result.json status is pass when the fake provider records at least two injected faults, every Debug Chat request succeeds, and total user-visible error rate stays at zero." + - "metrics_summary includes fake_provider_fault_count and status_counts for the same run window." + - "backend logs show request handling for the same run window without unexpected Traceback or task-leak findings." +evidence_required: + - metrics + - network + - api_diagnostic + - filesystem +diagnostics: + - "This is a fault-recovery probe, not a throughput benchmark." + - "Provider faults may be retried inside the provider/requester path; judge this case by fake_provider_fault_count plus user-visible success/error metrics." + - "The profile uses concurrency 1 because Debug Chat broadcasts assistant responses to every connection in a session, and failed responses do not carry the unique success token needed for concurrent attribution." +success_patterns: + - "Debug Chat WebSocket concurrency probe passed" + - "Streaming completed" +failure_patterns: + - "fake_provider_fault" + - "HTTP 503" + - "Timed out after" + - "All models failed during streaming setup" +expected_failures: + - "fake_provider_fault" + - "HTTP 503" +troubleshooting: + - backend-not-listening + - debug-chat-history-contaminates-automation + - local-agent-model-route-unavailable diff --git a/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-load.yaml b/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-load.yaml new file mode 100644 index 000000000..8a71c3558 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-load.yaml @@ -0,0 +1,81 @@ +id: langbot-fake-provider-debug-chat-load +title: "LangBot Debug Chat controlled fake-provider load probe" +mode: probe +area: performance +type: performance +priority: p1 +risk: medium +ci_eligible: false +tags: + - performance + - debug-chat + - websocket + - fake-provider + - load + - metrics +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_BACKEND_URL + - LANGBOT_FRONTEND_URL + - LANGBOT_E2E_LOGIN_USER +automation: skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs +automation_env: + - LANGBOT_BACKEND_URL + - LANGBOT_E2E_LOGIN_USER + - LANGBOT_FAKE_PROVIDER_PIPELINE_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_FAKE_PROVIDER_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +automation_debug_chat_load_requests: "12" +automation_debug_chat_load_concurrency: "4" +automation_debug_chat_load_timeout_ms: "30000" +automation_debug_chat_load_response_p95_ms: "5000" +automation_debug_chat_load_first_response_p95_ms: "3000" +automation_debug_chat_load_max_error_rate: "0" +automation_debug_chat_load_expected_prefix: "FAKEQA" +automation_debug_chat_load_prompt_template: '请只回复 "{expected}",不要解释,不要添加其他字符。' +automation_debug_chat_load_stream: "true" +automation_debug_chat_load_reset: "true" +metrics_thresholds_json: '{"response_p95_ms":{"max":5000},"first_response_p95_ms":{"max":3000},"error_rate":{"max":0}}' +load_profile_json: '{"requests":12,"concurrency":4,"path":"Pipeline Debug Chat WebSocket","provider":"controlled fake OpenAI-compatible provider","metric":"send-to-final-assistant-response"}' +setup_automation: + - "node:scripts/e2e/ensure-fake-provider-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_FAKE_PROVIDER_URL + - LANGBOT_FAKE_PROVIDER_BASE_URL + - LANGBOT_FAKE_PROVIDER_PID + - LANGBOT_FAKE_PROVIDER_PROVIDER_UUID + - LANGBOT_FAKE_PROVIDER_MODEL_UUID + - LANGBOT_FAKE_PROVIDER_PIPELINE_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +steps: + - "Start or reuse the local fake OpenAI-compatible provider." + - "Create or update the LangBot provider, model, and local-agent pipeline that points at the fake provider." + - "Reset the target Debug Chat session." + - "Open concurrent WebSocket Debug Chat connections and send unique deterministic prompts through the real backend pipeline." +checks: + - "automation-result.json status is pass when every request receives its own expected assistant response." + - "metrics_summary includes request count, concurrency, p50/p95 response latency, first response latency, throughput, and error rate." + - "thresholds_summary shows response_p95_ms, first_response_p95_ms, and error_rate pass." +evidence_required: + - metrics + - network + - api_diagnostic + - filesystem +diagnostics: + - "This probe removes external model latency from the measurement; it still exercises the live LangBot backend, provider requester, local-agent runner, pipeline, and Debug Chat WebSocket adapter." + - "Use this as the repeatable message-path baseline before comparing against Space or another real provider." +success_patterns: + - "Debug Chat WebSocket concurrency probe passed" + - "Streaming completed" +failure_patterns: + - "WebSocket connection error" + - "Timed out after" + - "Final assistant response did not include" + - "All models failed during streaming setup" +troubleshooting: + - backend-not-listening + - debug-chat-history-contaminates-automation + - local-agent-model-route-unavailable diff --git a/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-slow-load.yaml b/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-slow-load.yaml new file mode 100644 index 000000000..afa7de154 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-slow-load.yaml @@ -0,0 +1,88 @@ +id: langbot-fake-provider-debug-chat-slow-load +title: "LangBot Debug Chat slow fake-provider load probe" +mode: probe +area: performance +type: performance +priority: p1 +risk: medium +ci_eligible: false +tags: + - performance + - debug-chat + - websocket + - fake-provider + - slow-provider + - load + - metrics +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_BACKEND_URL + - LANGBOT_FRONTEND_URL + - LANGBOT_E2E_LOGIN_USER +automation: skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs +automation_env: + - LANGBOT_BACKEND_URL + - LANGBOT_E2E_LOGIN_USER + - LANGBOT_FAKE_PROVIDER_PIPELINE_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_FAKE_PROVIDER_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +automation_debug_chat_load_requests: "8" +automation_debug_chat_load_concurrency: "4" +automation_debug_chat_load_timeout_ms: "45000" +automation_debug_chat_load_response_p95_ms: "10000" +automation_debug_chat_load_first_response_p95_ms: "7000" +automation_debug_chat_load_max_error_rate: "0" +automation_debug_chat_load_expected_prefix: "SLOWQA" +automation_debug_chat_load_prompt_template: '请只回复 "{expected}",不要解释,不要添加其他字符。' +automation_debug_chat_load_stream: "true" +automation_debug_chat_load_reset: "true" +automation_fake_provider_first_token_delay_ms: "1000" +automation_fake_provider_chunk_delay_ms: "250" +automation_fake_provider_chunk_count: "4" +automation_fake_provider_fail_first_n: "0" +automation_fake_provider_fail_every_n: "0" +automation_fake_provider_fault_status: "500" +metrics_thresholds_json: '{"response_p95_ms":{"max":10000},"first_response_p95_ms":{"max":7000},"error_rate":{"max":0}}' +load_profile_json: '{"requests":8,"concurrency":4,"path":"Pipeline Debug Chat WebSocket","provider":"controlled slow fake OpenAI-compatible provider","metric":"send-to-final-assistant-response","provider_profile":{"first_token_delay_ms":1000,"chunk_delay_ms":250,"chunk_count":4}}' +setup_automation: + - "node:scripts/e2e/ensure-fake-provider-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_FAKE_PROVIDER_URL + - LANGBOT_FAKE_PROVIDER_BASE_URL + - LANGBOT_FAKE_PROVIDER_PID + - LANGBOT_FAKE_PROVIDER_PROVIDER_UUID + - LANGBOT_FAKE_PROVIDER_MODEL_UUID + - LANGBOT_FAKE_PROVIDER_PIPELINE_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +steps: + - "Configure the local fake provider with deterministic slow streaming latency." + - "Create or update the LangBot provider, model, and local-agent pipeline that points at the fake provider." + - "Reset the target Debug Chat session." + - "Open concurrent WebSocket Debug Chat connections and send unique deterministic prompts through the real backend pipeline." +checks: + - "automation-result.json status is pass when every request receives its own expected assistant response." + - "metrics_summary shows zero errors under the slow-provider profile." + - "thresholds_summary shows response_p95_ms, first_response_p95_ms, and error_rate pass." +evidence_required: + - metrics + - network + - api_diagnostic + - filesystem +diagnostics: + - "This probe keeps the model deterministic while injecting provider latency, so it catches backend timeout, streaming, and WebSocket backpressure issues without Space variability." + - "Compare with langbot-fake-provider-debug-chat-load to separate fixed LangBot overhead from provider-latency amplification." +success_patterns: + - "Debug Chat WebSocket concurrency probe passed" + - "Streaming completed" +failure_patterns: + - "WebSocket connection error" + - "Timed out after" + - "Final assistant response did not include" + - "All models failed during streaming setup" +troubleshooting: + - backend-not-listening + - debug-chat-history-contaminates-automation + - local-agent-model-route-unavailable diff --git a/skills/skills/langbot-testing/cases/langbot-fault-taxonomy-contract.yaml b/skills/skills/langbot-testing/cases/langbot-fault-taxonomy-contract.yaml new file mode 100644 index 000000000..2b990f837 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-fault-taxonomy-contract.yaml @@ -0,0 +1,35 @@ +id: langbot-fault-taxonomy-contract +title: "LangBot fault taxonomy and cleanup contract" +mode: probe +area: reliability +type: chaos +priority: p1 +risk: medium +ci_eligible: true +tags: + - reliability + - chaos + - contract + - synthetic +skills: + - langbot-testing +automation: skills/langbot-testing/probes/langbot-fault-taxonomy-contract.mjs +fault_model_json: '{"kind":"taxonomy-contract","destructive":false,"scenarios":["provider-timeout","plugin-runtime-disconnect","mcp-stdio-server-exit","operator-missing-login","transient-marketplace-timeout"]}' +steps: + - "Run `rtk bin/lbs test run langbot-fault-taxonomy-contract --dry-run` first; remove `--dry-run` after checking the evidence directory." + - "Automation validates that representative fault scenarios declare target, injected fault, expected status, recovery check, and cleanup." + - "Review metrics.json, fault-model.json, and automation-result.json under LBS_EVIDENCE_DIR." +checks: + - "automation-result.json status is pass." + - "Every scenario has an expected status in pass, fail, blocked, env_issue, or flaky." + - "Every scenario declares a cleanup action and recovery check." +evidence_required: + - metrics + - filesystem +diagnostics: + - "This is a non-destructive taxonomy contract probe; it does not inject real runtime faults." + - "Use it as a gate before adding live chaos cases that kill runtimes, route traffic through a proxy, or disrupt a backend dependency." +success_patterns: + - "Fault taxonomy contract declares status" +failure_patterns: + - "missing required scenario fields" diff --git a/skills/skills/langbot-testing/cases/langbot-live-backend-latency.yaml b/skills/skills/langbot-testing/cases/langbot-live-backend-latency.yaml new file mode 100644 index 000000000..1922d06f0 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-live-backend-latency.yaml @@ -0,0 +1,42 @@ +id: langbot-live-backend-latency +title: "LangBot live backend basic latency probe" +mode: probe +area: performance +type: performance +priority: p1 +risk: medium +ci_eligible: false +tags: + - performance + - live-backend + - latency + - metrics +skills: + - langbot-testing +env: + - LANGBOT_BACKEND_URL +automation: skills/langbot-testing/probes/langbot-live-backend-latency.mjs +metrics_thresholds_json: '{"backend_p95_ms":{"max":1000},"error_rate":{"max":0}}' +load_profile_json: '{"requests":12,"concurrency":2,"endpoints":["/healthz"]}' +steps: + - "Confirm the selected LangBot backend is the intended test target." + - "Run `rtk bin/lbs test run langbot-live-backend-latency --dry-run` first; remove `--dry-run` after checking LANGBOT_BACKEND_URL and evidence directory." + - "Automation sends a small request batch to LANGBOT_BACKEND_URL/healthz and records latency, status counts, and network errors." +checks: + - "automation-result.json status is pass when the backend responds and p95/error-rate thresholds pass." + - "automation-result.json status is env_issue when the backend is not reachable." + - "metrics.json and network.log are written under LBS_EVIDENCE_DIR." +evidence_required: + - metrics + - network + - api_diagnostic + - filesystem +diagnostics: + - "This probe measures backend health endpoint reachability latency only; it does not cover model/provider, browser, Debug Chat, RAG, or plugin runtime latency." +success_patterns: + - "Live backend latency probe passed" +failure_patterns: + - "Backend did not respond" + - "breached latency or error-rate thresholds" +troubleshooting: + - socks-proxy-without-socksio diff --git a/skills/skills/langbot-testing/cases/langbot-live-backend-log-health.yaml b/skills/skills/langbot-testing/cases/langbot-live-backend-log-health.yaml new file mode 100644 index 000000000..8ff911371 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-live-backend-log-health.yaml @@ -0,0 +1,45 @@ +id: langbot-live-backend-log-health +title: "LangBot live backend log health probe" +mode: probe +area: reliability +type: reliability +priority: p1 +risk: medium +ci_eligible: false +tags: + - reliability + - live-backend + - backend-log + - metrics +skills: + - langbot-testing +env: + - LANGBOT_BACKEND_URL +automation: skills/langbot-testing/probes/langbot-live-backend-log-health.mjs +metrics_thresholds_json: '{"fail_count":{"max":0}}' +load_profile_json: '{"lookback_seconds":300,"log_source":"LANGBOT_BACKEND_LOG or latest LANGBOT_REPO/data/logs/langbot-*.log"}' +steps: + - "Confirm the selected LangBot backend log belongs to the intended test target." + - "Run `rtk bin/lbs test run langbot-live-backend-log-health --dry-run` first; remove `--dry-run` after checking evidence directory and log source." + - "Automation scans the recent backend log window for fail-severity runtime findings such as Traceback, ImportError, ERROR, unclosed sessions, and unawaited coroutines." +checks: + - "automation-result.json status is pass only when fail_count is 0." + - "metrics_summary includes scanned_line_count, fail_count, warning_count, and finding_count." + - "findings.json and scanned-backend.log are written under LBS_EVIDENCE_DIR." +evidence_required: + - metrics + - backend_log + - filesystem +diagnostics: + - "Set LANGBOT_BACKEND_LOG to an explicit log path when the latest log file is not the run target." + - "Set LANGBOT_BACKEND_LOG_SINCE or LANGBOT_BACKEND_LOG_LOOKBACK_SECONDS to control the scan window." + - "This probe measures runtime log health; it does not prove user-facing Debug Chat, plugin, model, or RAG behavior." +success_patterns: + - "Live backend log health passed" +failure_patterns: + - "Traceback" + - "ImportError" + - "ERROR" + - "unclosed" +troubleshooting: + - socks-proxy-without-socksio diff --git a/skills/skills/langbot-testing/cases/langbot-live-control-plane-api.yaml b/skills/skills/langbot-testing/cases/langbot-live-control-plane-api.yaml new file mode 100644 index 000000000..2cd8ee2c7 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-live-control-plane-api.yaml @@ -0,0 +1,44 @@ +id: langbot-live-control-plane-api +title: "LangBot live control-plane API probe" +mode: probe +area: performance +type: performance +priority: p1 +risk: medium +ci_eligible: false +tags: + - performance + - reliability + - live-backend + - control-plane + - metrics +skills: + - langbot-testing +env: + - LANGBOT_BACKEND_URL +automation: skills/langbot-testing/probes/langbot-live-control-plane-api.mjs +metrics_thresholds_json: '{"error_rate":{"max":0},"response_shape_failures":{"max":0},"healthz_p95_ms":{"max":500},"system_info_p95_ms":{"max":1000}}' +load_profile_json: '{"requests":20,"concurrency":4,"endpoints":["/healthz","/api/v1/system/info"],"auth_required":false}' +steps: + - "Confirm the selected LangBot backend is the intended test target." + - "Run `rtk bin/lbs test run langbot-live-control-plane-api --dry-run` first; remove `--dry-run` after checking LANGBOT_BACKEND_URL and evidence directory." + - "Automation sends a small request batch to /healthz and /api/v1/system/info, then validates status code, JSON shape, and latency budgets." +checks: + - "automation-result.json status is pass when every control-plane request returns HTTP 200, JSON code 0, and required response fields." + - "metrics_summary includes per-endpoint p50/p95 latency, error rate, status counts, and response_shape_failures." + - "thresholds_summary shows error_rate, response_shape_failures, healthz_p95_ms, and system_info_p95_ms all pass." +evidence_required: + - metrics + - network + - api_diagnostic + - filesystem +diagnostics: + - "This probe measures unauthenticated backend control-plane readiness; it does not cover authenticated UI flows, Debug Chat, model calls, plugins, or RAG." + - "A system_info shape failure usually means the API contract or startup state changed and should be investigated before treating latency as healthy." +success_patterns: + - "Live control-plane API probe passed" +failure_patterns: + - "Backend did not respond" + - "breached shape, latency, or error-rate thresholds" +troubleshooting: + - socks-proxy-without-socksio diff --git a/skills/skills/langbot-testing/cases/langbot-overhead-accounting-contract.yaml b/skills/skills/langbot-testing/cases/langbot-overhead-accounting-contract.yaml new file mode 100644 index 000000000..650dfe7d9 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-overhead-accounting-contract.yaml @@ -0,0 +1,37 @@ +id: langbot-overhead-accounting-contract +title: "LangBot overhead accounting metrics contract" +mode: probe +area: performance +type: performance +priority: p1 +risk: medium +ci_eligible: true +tags: + - performance + - metrics + - contract + - synthetic +skills: + - langbot-testing +automation: skills/langbot-testing/probes/langbot-overhead-accounting-contract.mjs +metrics_thresholds_json: '{"sample_count":{"min":50},"langbot_overhead_p95_ms":{"max":25},"accounting_gap_max_ms":{"max":0.001}}' +load_profile_json: '{"kind":"synthetic-overhead-accounting","samples":80,"external_latency_segments":["provider","external_tool","network"]}' +steps: + - "Run `rtk bin/lbs test run langbot-overhead-accounting-contract --dry-run` first; remove `--dry-run` after checking the evidence directory." + - "Automation generates deterministic message-path latency samples and separates LangBot overhead from provider/tool/network latency." + - "Review metrics.json, thresholds.json, resource-log.json, and automation-result.json under LBS_EVIDENCE_DIR." +checks: + - "automation-result.json status is pass." + - "metrics_summary includes sample_count, langbot_overhead_p95_ms, e2e_latency_p95_ms, external_latency_p95_ms, and accounting_gap_max_ms." + - "thresholds_summary shows sample_count, langbot_overhead_p95_ms, and accounting_gap_max_ms all pass." +evidence_required: + - metrics + - resource_log + - filesystem +diagnostics: + - "This is a synthetic contract probe for the QA harness; it is not live product performance." + - "Use it to verify that reports can carry overhead accounting metrics before running live backend or browser performance probes." +success_patterns: + - "Overhead accounting contract passed" +failure_patterns: + - "breached one or more thresholds" diff --git a/skills/skills/langbot-testing/cases/langbot-space-debug-chat-concurrency-smoke.yaml b/skills/skills/langbot-testing/cases/langbot-space-debug-chat-concurrency-smoke.yaml new file mode 100644 index 000000000..4f9fc779b --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-space-debug-chat-concurrency-smoke.yaml @@ -0,0 +1,84 @@ +id: langbot-space-debug-chat-concurrency-smoke +title: "LangBot Debug Chat real Space-provider concurrency smoke" +mode: probe +area: performance +type: performance +priority: p1 +risk: high +ci_eligible: false +tags: + - performance + - debug-chat + - websocket + - space + - live-provider + - smoke + - metrics +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_BACKEND_URL + - LANGBOT_FRONTEND_URL + - LANGBOT_E2E_LOGIN_USER +automation: skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs +automation_env: + - LANGBOT_BACKEND_URL + - LANGBOT_E2E_LOGIN_USER + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_debug_chat_load_requests: "3" +automation_debug_chat_load_concurrency: "2" +automation_debug_chat_load_timeout_ms: "120000" +automation_debug_chat_load_response_p95_ms: "120000" +automation_debug_chat_load_max_error_rate: "0" +automation_debug_chat_load_expected_prefix: "SPACEQA" +automation_debug_chat_load_prompt_template: '请只回复 "{expected}",不要解释,不要添加其他字符。' +automation_debug_chat_load_stream: "true" +automation_debug_chat_load_reset: "true" +metrics_thresholds_json: '{"response_p95_ms":{"max":120000},"error_rate":{"max":0}}' +load_profile_json: '{"requests":3,"concurrency":2,"path":"Pipeline Debug Chat WebSocket","provider":"LangBot Space model route","metric":"send-to-final-assistant-response","classification":"smoke-not-benchmark"}' +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_PIPELINE_URL + - LANGBOT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_MODEL_UUID + - LANGBOT_E2E_MODEL_UUID +preconditions: + - "The selected local LangBot instance is safe for a low-volume real Space model smoke run." + - "Treat Space/provider/network failures as environment or dependency findings until fake-provider baseline evidence separates LangBot overhead." +steps: + - "Prepare a local-agent pipeline with a tested Space model and fallback models." + - "Reset the target Debug Chat session." + - "Open a small number of concurrent WebSocket Debug Chat connections and send unique deterministic prompts through the live Space provider path." +checks: + - "automation-result.json status is pass when every request receives its own expected assistant response." + - "metrics_summary includes request count, concurrency, p95 response latency, throughput, and error rate." + - "The report classifies the result as a live-provider smoke, not a stable LangBot overhead benchmark." +evidence_required: + - metrics + - network + - api_diagnostic + - filesystem +diagnostics: + - "This probe measures real user-path latency through Space and includes provider latency, model behavior, and network effects." + - "Compare with langbot-fake-provider-debug-chat-load before attributing slow or failed runs to LangBot itself." +success_patterns: + - "Debug Chat WebSocket concurrency probe passed" + - "Streaming completed" +failure_patterns: + - "invalid api key" + - "WebSocket connection error" + - "Timed out after" + - "Final assistant response did not include" + - "All models failed during streaming setup" +troubleshooting: + - local-agent-model-route-unavailable + - marketplace-network-flaky + - proxy-env-mismatch + - telemetry-proxy-noise diff --git a/skills/skills/langbot-testing/cases/langrag-kb-retrieve.yaml b/skills/skills/langbot-testing/cases/langrag-kb-retrieve.yaml new file mode 100644 index 000000000..d7dbd884e --- /dev/null +++ b/skills/skills/langbot-testing/cases/langrag-kb-retrieve.yaml @@ -0,0 +1,57 @@ +id: langrag-kb-retrieve +title: "LangRAG knowledge base ingests and retrieves a sentinel document" +mode: agent-browser +area: knowledge +type: feature +priority: p1 +risk: medium +ci_eligible: false +tags: + - langrag + - knowledge + - rag +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +automation: scripts/e2e/langrag-kb-retrieve.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE +automation_env_any: + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID|LANGBOT_RAG_KB_UUID +automation_expected_text: "azalea-cobalt-7421" +preconditions: + - "LangRAG is installed and initialized in the active LangBot instance." + - "A working embedding model is available, preferably chroma-all-MiniLM-L6-v2 for local repeatability." + - "LANGBOT_LOCAL_AGENT_RAG_KB_UUID points to a LangRAG knowledge base containing azalea-cobalt-7421." +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Knowledge." + - "Create a knowledge base with engine LangRAG." + - "Select a working embedding model, preferably local Chroma embedding model chroma-all-MiniLM-L6-v2." + - "Upload skills/langbot-testing/fixtures/rag/sentinel-doc.txt." + - "Wait until the document row status is Completed." + - "Open Retrieve Test and query: What is the local agent runner retrieval sentinel?" +checks: + - "UI: The knowledge base appears in the Knowledge sidebar." + - "UI: The uploaded document status becomes Completed." + - "UI: Retrieve Test shows the uploaded document content." + - "UI: Retrieve Test result contains azalea-cobalt-7421." + - "Console: No unexpected frontend errors appear during creation, upload, or retrieve." +evidence_required: + - ui + - screenshot + - console + - backend_log +diagnostics: + - "If no LangRAG engine is available, check /api/v1/knowledge/engines and install langbot-team/LangRAG." + - "If the embedding selector does not show a local Chroma model, confirm the model exists under embedding_models, not llm_models." +troubleshooting: + - marketplace-network-flaky + - dynamic-form-missing-config-id + - pipeline-form-controlled-warning diff --git a/skills/skills/langbot-testing/cases/langrag-parser-golden-e2e.yaml b/skills/skills/langbot-testing/cases/langrag-parser-golden-e2e.yaml new file mode 100644 index 000000000..c1c7da35f --- /dev/null +++ b/skills/skills/langbot-testing/cases/langrag-parser-golden-e2e.yaml @@ -0,0 +1,69 @@ +id: langrag-parser-golden-e2e +title: "LangRAG and GeneralParsers retrieve a structured golden document" +mode: agent-browser +area: knowledge +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - golden + - e2e + - langrag + - parser + - knowledge +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_REPO + - LANGBOT_WEB_REPO + - LANGBOT_RAG_PLUGIN_REPO + - LANGBOT_PARSER_PLUGIN_REPO +preconditions: + - "LANGBOT_REPO and plugin repo env values point to the worktrees intended for this golden parser/RAG run." + - "The active LangBot environment can build and install local LangRAG and GeneralParsers plugin packages." + - "A working embedding model is available before the Knowledge UI path starts." +steps: + - "Use LANGBOT_REPO as the active LangBot worktree and confirm it is the current master worktree for this run." + - "Start or verify the LangBot backend and frontend from LANGBOT_REPO and LANGBOT_WEB_REPO." + - "Build local plugin zips from LANGBOT_RAG_PLUGIN_REPO and LANGBOT_PARSER_PLUGIN_REPO using LANGBOT_REPO/.venv/bin/lbp build." + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Plugins and install or update the local LangRAG zip and GeneralParsers zip." + - "Wait until both plugins are initialized: langbot-team/LangRAG and langbot-team/GeneralParsers." + - "Navigate to Knowledge." + - "Create a knowledge base with engine LangRAG and a working embedding model, preferably chroma-all-MiniLM-L6-v2." + - "Keep index type Chunk for this golden case." + - "Upload skills/langbot-testing/fixtures/rag/parser-golden.html." + - "When the upload UI asks for a parser, select GeneralParsers for text/html." + - "Wait until the document row status is Completed." + - "Open Retrieve Test and query: What is the parser-rag golden sentinel and which parser/engine pair is documented? Return the exact sentinel and pair." +checks: + - "UI: Plugins shows langbot-team/LangRAG initialized." + - "UI: Plugins shows langbot-team/GeneralParsers initialized." + - "UI: The upload flow selects GeneralParsers for the HTML fixture, or the parser selector clearly defaults to GeneralParsers." + - "UI: The uploaded parser-golden.html document status becomes Completed." + - "UI: Retrieve Test result contains aurora-parser-rag-9137." + - "UI: Retrieve Test result contains GeneralParsers and LangRAG." + - "UI: Retrieve Test result preserves the table as Markdown, including '| Parser field | Golden value |'." + - "Console: No unexpected frontend errors appear during plugin install, KB creation, upload, or retrieve." + - "Logs: Backend/plugin logs show GeneralParsers parsed parser-golden.html and LangRAG used pre-parsed external parser content." +evidence_required: + - ui + - screenshot + - console + - backend_log +diagnostics: + - "If LangRAG is missing, check /api/v1/knowledge/engines for plugin id langbot-team/LangRAG." + - "If GeneralParsers is missing, check /api/v1/knowledge/parsers?mime_type=text/html for plugin id langbot-team/GeneralParsers." + - "If GeneralParsers local install fails on PyMuPDF, confirm the active LangBot master venv can import fitz and retry Upload Local." + - "If the document completes but table pipes are missing, inspect logs for LangRAG fallback to the internal FileParser instead of external parser content." + - "If embedding selection is empty, confirm the test embedding model exists under embedding_models, not llm_models." +troubleshooting: + - plugin-runtime-timeout + - plugin-dependency-install-offline + - marketplace-network-flaky + - dynamic-form-missing-config-id + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/langrag-sentinel-kb-discover.yaml b/skills/skills/langbot-testing/cases/langrag-sentinel-kb-discover.yaml new file mode 100644 index 000000000..1e1c246ff --- /dev/null +++ b/skills/skills/langbot-testing/cases/langrag-sentinel-kb-discover.yaml @@ -0,0 +1,38 @@ +id: langrag-sentinel-kb-discover +title: "Existing LangRAG sentinel knowledge base is discoverable" +mode: probe +area: knowledge +type: regression +priority: p1 +risk: medium +ci_eligible: false +tags: + - langrag + - knowledge + - rag + - fixture +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_BACKEND_URL + - LANGBOT_REPO + - LANGBOT_E2E_LOGIN_USER +automation: scripts/e2e/ensure-langrag-sentinel-kb.mjs +automation_expected_text: "azalea-cobalt-7421" +steps: + - "Run `rtk bin/lbs test run langrag-sentinel-kb-discover --dry-run` first; remove `--dry-run` only after readiness points at a local test LangBot instance." + - "Automation authenticates the local test user, lists knowledge bases, retrieves against each one, and looks for azalea-cobalt-7421." +checks: + - "automation-result.json status is pass when an existing KB retrieves the sentinel." + - "When run with `node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env`, LANGBOT_LOCAL_AGENT_RAG_KB_UUID is written to skills/.env.local." +evidence_required: + - api_diagnostic +diagnostics: + - "This case does not create a knowledge base. It only discovers a KB already prepared by langrag-kb-retrieve or by an equivalent local setup." +success_patterns: + - "Found LangRAG sentinel knowledge base" +failure_patterns: + - "No existing knowledge base retrieved expected sentinel" +troubleshooting: + - marketplace-network-flaky diff --git a/skills/skills/langbot-testing/cases/local-agent-basic-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-basic-debug-chat.yaml new file mode 100644 index 000000000..0b1cc1352 --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-basic-debug-chat.yaml @@ -0,0 +1,71 @@ +id: local-agent-basic-debug-chat +title: "Local Agent Debug Chat returns a deterministic streaming response" +mode: agent-browser +area: pipeline +type: smoke +priority: p0 +risk: medium +ci_eligible: false +tags: + - local-agent + - pipeline + - streaming +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_prompt: "请只回复 OK,用于前端调试测试。" +automation_expected_text: "OK" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Pipelines and open the target local-agent pipeline." + - "Open Configuration > AI." + - "Use runner Default or the pluginized langbot/local-agent runner." + - "Select a model that is known to answer Debug Chat in the current environment." + - "Clear Knowledge Bases unless this run intentionally combines RAG with the smoke path." + - "Save the pipeline." + - "Open Debug Chat." + - "Ensure the stream switch is enabled when the UI exposes it." + - "Send: 请只回复 OK,用于前端调试测试。" +checks: + - "UI: The user message appears in Debug Chat." + - "UI: A Bot message appears and contains OK." + - "Console: No unexpected frontend runtime errors appear during the send/receive path." + - "Logs: Backend logs show the debug chat request completed on the streaming path instead of timing out in plugin/runtime calls." +evidence_required: + - ui + - screenshot + - console + - backend_log +diagnostics: + - "Provider errors such as model_not_found or no available channel mean the selected model is unavailable; switch to a known-good model before diagnosing local-agent." + - "Use GET /api/v1/pipelines/{uuid} only to confirm the saved runner and model config." +success_patterns: + - "Processing request from person_websocket" + - "Streaming completed" +failure_patterns: + - "Action invoke_llm_stream call timed out" + - "Task exception was never retrieved" + - "survey widget blocks debug chat" +troubleshooting: + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat diff --git a/skills/skills/langbot-testing/cases/local-agent-context-compaction-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-context-compaction-debug-chat.yaml new file mode 100644 index 000000000..7c0d6a32a --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-context-compaction-debug-chat.yaml @@ -0,0 +1,87 @@ +id: local-agent-context-compaction-debug-chat +title: "Local Agent compacts long Debug Chat history and preserves older facts" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - local-agent + - pipeline + - context + - compaction +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot/local-agent/default" +automation_runner_config_patch_json: '{"context-window-tokens":225,"context-reserve-tokens":50,"context-keep-recent-tokens":30,"context-summary-tokens":105,"knowledge-bases":[]}' +automation_restore_runner_config: "1" +automation_reset_debug_chat: "1" +automation_debug_chat_session_type: "person" +automation_expected_text: "qa_compaction_sentinel_7391" +automation_response_timeout_ms: "180000" +automation_prompts_json: '[{"prompt":"请记住这个用于 local-agent context compaction 回归测试的暗号:qa_compaction_sentinel_7391。请只回复 MEMORY_SET。","expected_text":"MEMORY_SET","response_timeout_ms":"180000"},{"prompt":"下面这轮只用于制造长历史压力,内容没有业务含义。请忽略填充内容,最后只回复 CONTEXT_PRESSURE_READY。填充片段 A001 context padding for local-agent compaction. A002 context padding for local-agent compaction. A003 context padding for local-agent compaction. A004 context padding for local-agent compaction. A005 context padding for local-agent compaction. A006 context padding for local-agent compaction. A007 context padding for local-agent compaction. A008 context padding for local-agent compaction. A009 context padding for local-agent compaction. A010 context padding for local-agent compaction. A011 context padding for local-agent compaction. A012 context padding for local-agent compaction. A013 context padding for local-agent compaction. A014 context padding for local-agent compaction. A015 context padding for local-agent compaction. A016 context padding for local-agent compaction. A017 context padding for local-agent compaction. A018 context padding for local-agent compaction. A019 context padding for local-agent compaction. A020 context padding for local-agent compaction. A021 context padding for local-agent compaction. A022 context padding for local-agent compaction. A023 context padding for local-agent compaction. A024 context padding for local-agent compaction. A025 context padding for local-agent compaction. A026 context padding for local-agent compaction. A027 context padding for local-agent compaction. A028 context padding for local-agent compaction. A029 context padding for local-agent compaction. A030 context padding for local-agent compaction. A031 context padding for local-agent compaction. A032 context padding for local-agent compaction. A033 context padding for local-agent compaction. A034 context padding for local-agent compaction. A035 context padding for local-agent compaction. A036 context padding for local-agent compaction. A037 context padding for local-agent compaction. A038 context padding for local-agent compaction. A039 context padding for local-agent compaction. A040 context padding for local-agent compaction.","expected_text":"CONTEXT_PRESSURE_READY","response_timeout_ms":"180000"},{"prompt":"刚才第一轮我要求你记住的测试暗号是什么?请只回复暗号本身,不要解释。","expected_text":"qa_compaction_sentinel_7391","response_timeout_ms":"180000"}]' +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +preconditions: + - "The selected model route can follow short deterministic instructions across multiple Debug Chat turns." +steps: + - "Open the target local-agent pipeline through LANGBOT_FRONTEND_URL." + - "Use the authenticated browser token only inside automation to GET and PUT /api/v1/pipelines/{uuid}." + - "Assert the saved runner is plugin:langbot/local-agent/default." + - "Temporarily set context-window-tokens, context-reserve-tokens, context-keep-recent-tokens, and context-summary-tokens to force compaction, and clear knowledge-bases so RAG does not answer the memory question." + - "Reset the person Debug Chat session for the target pipeline." + - "Send the sentinel memory prompt and wait for MEMORY_SET." + - "Send the long padding prompt and wait for CONTEXT_PRESSURE_READY." + - "Ask for the original sentinel and wait for qa_compaction_sentinel_7391." + - "Restore the original runner config." +checks: + - "UI: All three user messages appear in Debug Chat." + - "UI: The final Bot message contains qa_compaction_sentinel_7391." + - "API diagnostic: pipeline-config-diagnostic.json shows patched=true and patch_keys include the four token context compaction fields plus knowledge-bases." + - "API diagnostic: pipeline-config-restore-diagnostic.json shows the original runner config was restored." + - "Logs: Backend completes the multi-turn Debug Chat path without runner timeout or model setup failure." + - "Console: No unexpected frontend runtime errors appear during the run." +evidence_required: + - ui + - screenshot + - console + - backend_log + - api_diagnostic +diagnostics: + - "If the final sentinel is missing, inspect whether pipeline-config-diagnostic.json targeted ai.runner_config[runnerId], cleared knowledge-bases, and whether the backend log shows the local-agent runner loading the small context settings." + - "If the model ignores deterministic replies, rerun with a known-good model route before diagnosing ContextAssembler." + - "If restore fails, use pipeline-config-restore-diagnostic.json and GET /api/v1/pipelines/{uuid} to confirm the current saved config before retrying." +success_patterns: + - "Processing request from person_websocket" + - "Streaming completed" +failure_patterns: + - "Action invoke_llm_stream call timed out" + - "All models failed during streaming setup" + - "Task exception was never retrieved" + - "survey widget blocks debug chat" +troubleshooting: + - local-agent-model-route-unavailable + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat + - debug-chat-history-contaminates-automation diff --git a/skills/skills/langbot-testing/cases/local-agent-effective-prompt-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-effective-prompt-debug-chat.yaml new file mode 100644 index 000000000..d19d4a109 --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-effective-prompt-debug-chat.yaml @@ -0,0 +1,67 @@ +id: local-agent-effective-prompt-debug-chat +title: "Local Agent consumes host effective prompt after PromptPreProcessing" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - local-agent + - prompt + - plugin + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_prompt: "qa-effective-prompt" +automation_expected_text: "PROMPT_PREPROCESS_OK" +automation_response_timeout_ms: "180000" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "case:qa-plugin-smoke-live-install" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +preconditions: + - "The target pipeline is safe to modify and can bind the fixture plugin through Extensions." +steps: + - "Install or enable the bundled qa-plugin-smoke fixture plugin." + - "Confirm the fixture plugin is bound to the target pipeline through Extensions." + - "Open the target local-agent pipeline." + - "Open Configuration > AI." + - "Use runner Default or the pluginized langbot/local-agent runner." + - "Select a model that is known to follow system prompts in Debug Chat." + - "Save the pipeline." + - "Open Debug Chat." + - "Send: qa-effective-prompt" +checks: + - "UI: A Bot message appears and contains PROMPT_PREPROCESS_OK." + - "Logs: PromptPreProcessing runs for the fixture plugin before the local-agent runner invokes the model." + - "Logs: Backend completes the debug-chat request without plugin/runtime timeout." + - "Console: No unexpected frontend runtime errors appear during configuration or chat." +evidence_required: + - ui + - console + - backend_log +diagnostics: + - "If the bot does not return PROMPT_PREPROCESS_OK, verify the fixture plugin is installed, enabled, and bound to the pipeline before diagnosing ctx.adapter.extra.prompt." + - "If the plugin event runs but the answer ignores the sentinel, inspect whether the runner is using ctx.adapter.extra.prompt instead of static runner config prompt." +troubleshooting: + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat diff --git a/skills/skills/langbot-testing/cases/local-agent-multimodal-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-multimodal-debug-chat.yaml new file mode 100644 index 000000000..298b59dca --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-multimodal-debug-chat.yaml @@ -0,0 +1,71 @@ +id: local-agent-multimodal-debug-chat +title: "Local Agent Debug Chat preserves uploaded image input" +mode: agent-browser +area: pipeline +type: regression +priority: p2 +risk: medium +ci_eligible: false +tags: + - local-agent + - multimodal + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_prompt: "I attached an image. Reply only IMAGE_OK if you received the image." +automation_expected_text: "IMAGE_OK" +automation_image_base64_fixture: "skills/langbot-testing/fixtures/multimodal/red-square.png.base64" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +preconditions: + - "The selected model route accepts image input, or the case is intentionally checking graceful provider rejection." +steps: + - "Prepare a small PNG file for upload. The bundled fixture base64 is at skills/langbot-testing/fixtures/multimodal/red-square.png.base64 if a temporary file is needed." + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Pipelines and open the target local-agent pipeline." + - "Open Configuration > AI." + - "Use runner Default or the pluginized langbot/local-agent runner." + - "Select a model that supports image input in the current environment, or use a known model that at least accepts the uploaded image payload." + - "Save the pipeline." + - "Open Debug Chat." + - "Attach the PNG through the image/file upload control. Prefer the bundled 64x64 red-square fixture; 1x1 images may be rejected by some model providers before runner behavior is exercised." + - "Confirm the user compose area or sent message shows the image attachment." + - "Send: I attached an image. Reply only IMAGE_OK if you received the image." +checks: + - "UI: The sent User message shows an image attachment, not just text." + - "UI: The Bot message contains IMAGE_OK." + - "Network or logs: The browser sends an image upload request, or backend logs show the local-agent input contains an image." + - "Console: No unexpected frontend runtime errors appear during upload or Debug Chat." +evidence_required: + - ui + - screenshot + - console + - network + - backend_log +diagnostics: + - "If the model cannot process image input, repeat with a multimodal-capable model before diagnosing local-agent." + - "For RAG plus multimodal coverage, keep a KB bound and verify the image remains visible while the answer uses the KB sentinel." +troubleshooting: + - local-agent-model-route-unavailable + - plugin-runtime-timeout + - proxy-env-mismatch + - provider-image-parse-error + - survey-widget-blocks-debug-chat diff --git a/skills/skills/langbot-testing/cases/local-agent-nonstreaming-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-nonstreaming-debug-chat.yaml new file mode 100644 index 000000000..910032d79 --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-nonstreaming-debug-chat.yaml @@ -0,0 +1,64 @@ +id: local-agent-nonstreaming-debug-chat +title: "Local Agent Debug Chat returns a deterministic non-streaming response" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: medium +ci_eligible: false +tags: + - local-agent + - pipeline + - non-streaming +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_prompt: "Reply only NONSTREAM_OK." +automation_expected_text: "NONSTREAM_OK" +automation_stream_output: "0" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Pipelines and open the target local-agent pipeline." + - "Open Configuration > AI." + - "Use runner Default or the pluginized langbot/local-agent runner." + - "Select a model that is known to answer Debug Chat in the current environment." + - "Save the pipeline." + - "Open Debug Chat." + - "Disable the stream switch when the UI exposes it." + - "Send: Reply only NONSTREAM_OK." +checks: + - "UI: The user message appears in Debug Chat." + - "UI: A Bot message appears and contains NONSTREAM_OK." + - "Logs: Backend completes the request as a normal response rather than only relying on the streaming-completed path." + - "Console: No unexpected frontend runtime errors appear during the send/receive path." +evidence_required: + - ui + - console + - backend_log +diagnostics: + - "If the UI still streams after the switch is disabled, inspect the adapter streaming capability and runner config before diagnosing the model." + - "Use GET /api/v1/pipelines/{uuid} only to confirm the saved runner and model config." +troubleshooting: + - local-agent-model-route-unavailable + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat diff --git a/skills/skills/langbot-testing/cases/local-agent-plugin-tool-call-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-plugin-tool-call-debug-chat.yaml new file mode 100644 index 000000000..1c37e2fb1 --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-plugin-tool-call-debug-chat.yaml @@ -0,0 +1,68 @@ +id: local-agent-plugin-tool-call-debug-chat +title: "Local Agent can call a plugin-provided tool" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - local-agent + - plugin + - tools + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_prompt: "Call the qa_plugin_echo tool with exactly this text: plugin-tool-ok-local-agent. Return only the tool result." +automation_expected_text: "qa-plugin-smoke:plugin-tool-ok-local-agent" +automation_response_timeout_ms: "180000" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "case:qa-plugin-smoke-live-install" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +preconditions: + - "The selected model route supports function/tool calling." +steps: + - "Install or enable the bundled qa-plugin-smoke fixture plugin." + - "Confirm /api/v1/tools or the plugin detail shows qa_plugin_echo." + - "Confirm the fixture plugin is bound to the target pipeline through Extensions, or that all plugins are enabled." + - "Open the target local-agent pipeline." + - "Use runner Default or the pluginized langbot/local-agent runner." + - "Select a model with function-calling ability that is known to work with tools in the current environment." + - "Open Debug Chat." + - "Send: Call the qa_plugin_echo tool with exactly this text: plugin-tool-ok-local-agent. Return only the tool result." +checks: + - "UI: Debug Chat bot response contains qa-plugin-smoke:plugin-tool-ok-local-agent." + - "Logs: Backend logs show the plugin tool call was executed, not only listed." + - "Console: No unexpected frontend errors appear during Debug Chat." +evidence_required: + - ui + - console + - backend_log + - api_diagnostic +diagnostics: + - "If qa_plugin_echo is not listed, rebuild and reinstall the qa-plugin-smoke fixture plugin." + - "If the selected model returns model_not_found or no available channel only when tools are provided, switch to a known-good function-calling model before diagnosing plugin tools or local-agent." +troubleshooting: + - local-agent-model-route-unavailable + - tool-name-collision-between-mcp-and-plugin + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat diff --git a/skills/skills/langbot-testing/cases/local-agent-rag-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-rag-debug-chat.yaml new file mode 100644 index 000000000..b7e715b16 --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-rag-debug-chat.yaml @@ -0,0 +1,76 @@ +id: local-agent-rag-debug-chat +title: "Local Agent Debug Chat answers from a LangRAG knowledge base" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - local-agent + - langrag + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot/local-agent/default" +automation_runner_config_patch_json: '{"knowledge-bases":["${LANGBOT_LOCAL_AGENT_RAG_KB_UUID}"]}' +automation_restore_runner_config: "1" +automation_reset_debug_chat: "1" +automation_prompt: "Using the knowledge base, what is the local agent runner retrieval sentinel? Return only the sentinel." +automation_expected_text: "azalea-cobalt-7421" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID +preconditions: + - "The target pipeline already has a text-capable model route that is available for this run." +steps: + - "Ensure case langrag-kb-retrieve has produced a knowledge base containing sentinel azalea-cobalt-7421." + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Pipelines and open the target pipeline." + - "Open Configuration > AI." + - "Use runner Default and add the LangRAG knowledge base to Knowledge Bases." + - "Save the pipeline." + - "Open Debug Chat." + - "Send: Using the knowledge base, what is the local agent runner retrieval sentinel? Return only the sentinel." +checks: + - "UI: The AI config shows the selected knowledge base under Knowledge Bases." + - "UI: The pipeline saves successfully." + - "UI: Debug Chat shows a Bot message containing azalea-cobalt-7421." + - "Console: No unexpected frontend errors appear during configuration or chat." + - "Logs: Backend logs show the debug chat request completed instead of plugin/runtime timeout." + - "API diagnostic: pipeline-config-diagnostic.json shows knowledge-bases and model were temporarily patched." + - "API diagnostic: pipeline-config-restore-diagnostic.json shows the original runner config was restored." +evidence_required: + - ui + - screenshot + - console + - backend_log + - api_diagnostic +diagnostics: + - "Use GET /api/v1/pipelines/{uuid} only to confirm the saved runner_config contains the knowledge base uuid." + - "If the bot ignores the knowledge base, rerun Retrieve Test before debugging the runner." +troubleshooting: + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat diff --git a/skills/skills/langbot-testing/cases/local-agent-rag-multimodal-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-rag-multimodal-debug-chat.yaml new file mode 100644 index 000000000..4e0759363 --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-rag-multimodal-debug-chat.yaml @@ -0,0 +1,74 @@ +id: local-agent-rag-multimodal-debug-chat +title: "Local Agent preserves image input while using RAG context" +mode: agent-browser +area: pipeline +type: regression +priority: p2 +risk: high +ci_eligible: false +tags: + - local-agent + - rag + - multimodal + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_runner_config_patch_json: '{"knowledge-bases":["${LANGBOT_LOCAL_AGENT_RAG_KB_UUID}"]}' +automation_restore_runner_config: "1" +automation_reset_debug_chat: "1" +automation_prompt: "I attached an image. Using the knowledge base, what is the local agent runner retrieval sentinel? Return only the sentinel." +automation_expected_text: "azalea-cobalt-7421" +automation_image_base64_fixture: "skills/langbot-testing/fixtures/multimodal/red-square.png.base64" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID +preconditions: + - "The selected model route accepts image input, or the case is intentionally checking graceful provider rejection." +steps: + - "Ensure case langrag-kb-retrieve has produced a knowledge base containing sentinel azalea-cobalt-7421." + - "Prepare a PNG file for upload. Prefer the bundled fixture at skills/langbot-testing/fixtures/multimodal/red-square.png.base64." + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Pipelines and open the target local-agent pipeline." + - "Open Debug Chat." + - "Attach the PNG through the image/file upload control." + - "Send: I attached an image. Use the knowledge base and reply only the unique sentinel from the knowledge base." +checks: + - "UI: The sent User message shows an image attachment." + - "UI: The Bot message contains the expected KB sentinel." + - "Logs: The same backend request line contains [Image], and the request completes without runner.llm_error." + - "Console: No unexpected frontend runtime errors appear during upload or Debug Chat." +evidence_required: + - ui + - screenshot + - console + - backend_log +diagnostics: + - "This case does not require the model to identify image content; provider vision quality is not the local-agent runner contract." + - "If the provider rejects the image, retest with the 64x64 red-square fixture and a known multimodal-capable model route." + - "If the image is visible but the sentinel is absent, first verify the KB is bound and retrievable in local-agent-rag-debug-chat." +troubleshooting: + - plugin-runtime-timeout + - proxy-env-mismatch + - provider-image-parse-error + - survey-widget-blocks-debug-chat diff --git a/skills/skills/langbot-testing/cases/local-agent-steering-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-steering-debug-chat.yaml new file mode 100644 index 000000000..973822f2b --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-steering-debug-chat.yaml @@ -0,0 +1,87 @@ +id: local-agent-steering-debug-chat +title: "Local Agent injects a follow-up into an active run" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - local-agent + - pipeline + - steering + - tools +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/local-agent-steering-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot/local-agent/default" +automation_reset_debug_chat: "1" +automation_stream_output: "1" +automation_prompt: "You are running the LangBot steering E2E test. First call the qa_plugin_sleep tool with seconds=8 and text=steering-e2e-anchor. Do not answer before the tool result is available. After the tool returns, answer the latest user follow-up. If no follow-up was injected, reply only STEERING_NO_FOLLOWUP." +automation_expected_text: "qa_steering_sentinel_6194" +automation_response_timeout_ms: "240000" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "case:qa-plugin-smoke-live-install" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +preconditions: + - "The selected model route supports function/tool calling and can call qa_plugin_sleep." +steps: + - "Open the target local-agent pipeline." + - "Assert the saved runner is plugin:langbot/local-agent/default." + - "Reset the person Debug Chat session for the target pipeline." + - "Assert /api/v1/tools contains qa_plugin_sleep." + - "Send the first prompt that instructs the model to call qa_plugin_sleep for 8 seconds." + - "After 1 second, send a second Debug Chat message containing qa_steering_sentinel_6194 while the first run is still active." + - "Wait for the final assistant response." +checks: + - "UI: Debug Chat shows two new user messages but only one new assistant response." + - "UI: The only new assistant response contains qa_steering_sentinel_6194." + - "API diagnostic: pipeline-config-diagnostic.json confirms the local-agent runner id." + - "API diagnostic: tool-diagnostic.json confirms qa_plugin_sleep is exposed." + - "Logs: Backend handles the follow-up without starting an independent second assistant response." + - "Console: No unexpected frontend runtime errors appear during Debug Chat." +evidence_required: + - ui + - screenshot + - console + - backend_log + - api_diagnostic +diagnostics: + - "If qa_plugin_sleep is missing, rebuild and reinstall or resync the qa-plugin-smoke fixture plugin, then restart the backend." + - "If the UI cannot send the follow-up because the input is disabled during a run, the WebUI path does not yet support steering." + - "If two assistant responses appear, the follow-up likely started a separate run instead of being claimed as steering." + - "If the model replies STEERING_NO_FOLLOWUP, inspect Host steering claim logs and local-agent steering_pull calls." +success_patterns: + - "Processing request from person_websocket" + - "Steering" + - "Streaming completed" +failure_patterns: + - "STEERING_NO_FOLLOWUP" + - "Action invoke_llm_stream call timed out" + - "All models failed during streaming setup" + - "Task exception was never retrieved" + - "survey widget blocks debug chat" +troubleshooting: + - local-agent-model-route-unavailable + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat + - debug-chat-history-contaminates-automation diff --git a/skills/skills/langbot-testing/cases/mcp-stdio-register.yaml b/skills/skills/langbot-testing/cases/mcp-stdio-register.yaml new file mode 100644 index 000000000..6c9857ba9 --- /dev/null +++ b/skills/skills/langbot-testing/cases/mcp-stdio-register.yaml @@ -0,0 +1,59 @@ +id: mcp-stdio-register +title: "MCP stdio fixture is registered and exposes qa_mcp_echo" +mode: agent-browser +area: mcp +type: smoke +priority: p1 +risk: medium +ci_eligible: false +tags: + - mcp + - tools + - fixture + - preflight +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +automation: scripts/e2e/mcp-stdio-register.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE +preconditions: + - "The active LangBot instance is safe to create or update the qa-local-stdio MCP server." + - "box.local.allowed_mount_roots allows the bundled MCP fixture path when Box runs stdio MCP servers." + - "The bundled dependency-free fixture skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py exists." +steps: + - "Open LANGBOT_FRONTEND_URL with the configured browser profile." + - "Use the browser token to upsert MCP server qa-local-stdio in stdio mode." + - "Point the server command to python with the bundled qa_mcp_echo_server.py fixture path." + - "Poll /api/v1/tools and /api/v1/mcp/servers/qa-local-stdio until qa_mcp_echo is visible." +checks: + - "API diagnostic: qa-local-stdio runtime_status is connected." + - "API diagnostic: runtime_tool_names includes qa_mcp_echo." + - "API diagnostic: /api/v1/tools includes qa_mcp_echo." + - "Console and network logs contain no unexpected frontend/runtime failures." +evidence_required: + - screenshot + - console + - network + - api_diagnostic +diagnostics: + - "If the server does not connect, run node scripts/e2e/mcp-stdio-fixture.mjs to validate the fixture outside LangBot first." + - "If /api/v1/tools keeps showing an old MCP name, rerun this case after restarting LangBot so the server registry is fresh." + - "If Box reports an allowed mount root error, update the local LangBot data config rather than changing shared test assets." +success_patterns: + - "MCP server qa-local-stdio is connected and exposes qa_mcp_echo" +failure_patterns: + - "MCP fixture not found" + - "Browser profile has no localStorage token" + - "did not expose qa_mcp_echo" +troubleshooting: + - mcp-stdio-args-not-applied + - backend-not-listening + - plugin-runtime-timeout + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/mcp-stdio-tool-call.yaml b/skills/skills/langbot-testing/cases/mcp-stdio-tool-call.yaml new file mode 100644 index 000000000..593eecba3 --- /dev/null +++ b/skills/skills/langbot-testing/cases/mcp-stdio-tool-call.yaml @@ -0,0 +1,88 @@ +id: mcp-stdio-tool-call +title: "MCP stdio server exposes a tool that Local Agent can call" +mode: agent-browser +area: mcp +type: feature +priority: p1 +risk: high +ci_eligible: false +tags: + - mcp + - tools + - local-agent +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_prompt: "Call the qa_mcp_echo MCP tool with exactly this text: mcp-ok-local-agent. Return only the tool result." +automation_expected_text: "qa_mcp_echo:mcp-ok-local-agent" +automation_response_timeout_ms: "180000" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "case:mcp-stdio-register" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +failure_patterns: + - "qa-plugin-smoke:mcp-ok-local-agent" + - "qa_echo:mcp-ok-local-agent" + - "Agent runner temporarily unavailable" + - "runner.llm_error" + - "model_not_found" + - "no available channel for model" +preconditions: + - "box.local.allowed_mount_roots includes the bundled MCP fixture directory when LangBot runs stdio MCP servers through Box." + - "The selected model route supports function/tool calling." +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to MCP Servers." + - "Create a Stdio MCP server named qa-local-stdio." + - "Set command to python." + - "Add one argument: the absolute path to skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py." + - "Click Test and wait for the test task to finish." + - "Submit the server." + - "Confirm the server detail page shows Tools: 1 and qa_mcp_echo." + - "Open the target local-agent pipeline." + - "Use runner Default or the pluginized langbot/local-agent runner." + - "Select a model with function-calling ability that is known to work with tools in the current environment." + - "Open Debug Chat." + - "Send: Call the qa_mcp_echo tool with exactly this text: mcp-ok-local-agent. Return only the tool result." +checks: + - "UI: The MCP server is connected after submit." + - "UI: The MCP detail page shows qa_mcp_echo." + - "API diagnostic: /api/v1/tools contains qa_mcp_echo." + - "UI: Debug Chat bot response contains qa_mcp_echo:mcp-ok-local-agent." + - "Logs: Backend logs show the MCP tool call was executed, not only listed." + - "Console: No unexpected frontend errors appear during MCP form use or Debug Chat." +evidence_required: + - ui + - console + - backend_log + - api_diagnostic +diagnostics: + - "Run node scripts/e2e/mcp-stdio-fixture.mjs to verify the bundled stdio fixture can list and call qa_mcp_echo without involving a model provider." + - "Run node scripts/e2e/mcp-stdio-register.mjs to upsert qa-local-stdio in LangBot and verify /api/v1/tools exposes qa_mcp_echo." + - "If backend logs show host_path is outside allowed_mount_roots, add the fixture directory to box.local.allowed_mount_roots in the local LangBot data config." + - "If backend logs show uv: not found, refresh qa-local-stdio with command python instead of uv." + - "If /api/v1/tools still shows qa_echo instead of qa_mcp_echo, refresh qa-local-stdio with the register diagnostic or restart the backend." + - "If Debug Chat cannot click Send, close the survey widget or other overlays before retrying." + - "If the model returns model_not_found or no available channel only when tools are provided, switch to a known-good function-calling model before diagnosing MCP or local-agent." +troubleshooting: + - mcp-stdio-args-not-applied + - tool-name-collision-between-mcp-and-plugin + - local-agent-model-route-unavailable + - survey-widget-blocks-debug-chat + - plugin-runtime-timeout diff --git a/skills/skills/langbot-testing/cases/pipeline-debug-chat-performance.yaml b/skills/skills/langbot-testing/cases/pipeline-debug-chat-performance.yaml new file mode 100644 index 000000000..266cbb57d --- /dev/null +++ b/skills/skills/langbot-testing/cases/pipeline-debug-chat-performance.yaml @@ -0,0 +1,80 @@ +id: pipeline-debug-chat-performance +title: "Pipeline Debug Chat user-path performance probe" +mode: agent-browser +area: pipeline +type: performance +priority: p1 +risk: medium +ci_eligible: false +tags: + - performance + - pipeline + - debug-chat + - user-path + - metrics +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +env_any: + - LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_E2E_PROMPT + - LANGBOT_E2E_EXPECTED_TEXT + - LANGBOT_E2E_RESPONSE_TIMEOUT_MS +automation_env_any: + - LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME +automation_prompt: "请只回复 OK,用于性能测试。" +automation_expected_text: "OK" +automation_response_timeout_ms: "120000" +automation_reset_debug_chat: "true" +automation_debug_chat_response_p95_ms: "120000" +automation_debug_chat_max_error_rate: "0" +metrics_thresholds_json: '{"response_p95_ms":{"max":120000},"error_rate":{"max":0}}' +load_profile_json: '{"prompts":1,"browser":true,"path":"Pipeline Debug Chat","metric":"send-to-visible-completion"}' +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_PIPELINE_URL + - LANGBOT_PIPELINE_NAME +preconditions: + - "LANGBOT_PIPELINE_URL or LANGBOT_PIPELINE_NAME points to the pipeline intended for this Debug Chat performance run." + - "The target pipeline is safe to reset Debug Chat history for this run." + - "The target pipeline has a known-good runner/model; provider latency should be interpreted separately from LangBot overhead." +steps: + - "Open LANGBOT_FRONTEND_URL with the prepared browser profile." + - "Open the target pipeline and select Debug Chat." + - "Reset Debug Chat history through the backend API when configured." + - "Send the deterministic prompt and wait for the expected assistant response." +checks: + - "automation-result.json status is pass when the expected assistant response appears." + - "metrics_summary includes response_p50_ms, response_p95_ms, error_rate, and total_duration_ms." + - "thresholds_summary shows response_p95_ms and error_rate pass." +evidence_required: + - ui + - screenshot + - console + - network + - metrics +diagnostics: + - "This case measures browser-visible send-to-completion latency; it does not split provider latency from LangBot overhead." + - "Use backend logs and provider diagnostics to explain slow runs before calling them LangBot regressions." +success_patterns: + - "Processing request from person_websocket" + - "Streaming completed" +failure_patterns: + - "Action invoke_llm_stream call timed out" + - "Task exception was never retrieved" + - "All models failed during streaming setup" +troubleshooting: + - debug-chat-history-contaminates-automation + - local-agent-model-route-unavailable + - plugin-runtime-timeout + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/pipeline-debug-chat.yaml b/skills/skills/langbot-testing/cases/pipeline-debug-chat.yaml new file mode 100644 index 000000000..1f0c16f4e --- /dev/null +++ b/skills/skills/langbot-testing/cases/pipeline-debug-chat.yaml @@ -0,0 +1,64 @@ +id: pipeline-debug-chat +title: "Pipeline Debug Chat returns a bot response" +mode: agent-browser +area: pipeline +type: smoke +priority: p0 +risk: medium +ci_eligible: false +tags: + - smoke + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +env_any: + - LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_E2E_PROMPT + - LANGBOT_E2E_EXPECTED_TEXT +automation_env_any: + - LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME +automation_prompt: "请只回复 OK,用于前端调试测试。" +automation_expected_text: "OK" +preconditions: + - "LANGBOT_PIPELINE_URL or LANGBOT_PIPELINE_NAME points to the pipeline intended for this generic Debug Chat smoke run." + - "The target pipeline is safe to save or already configured with a known-good runner/model." +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Pipelines." + - "Open the target pipeline." + - "Select Debug Chat." + - "Send a short deterministic prompt, such as 请只回复 OK,用于前端调试测试。" +checks: + - "UI: The page shows a User message containing the prompt." + - "UI: The page shows a Bot message containing the expected response, such as OK." + - "Console: No unexpected frontend errors appear during the interaction." + - "Visual: If screenshot/vision is available, the chat panel is not blank, overlapped, or visually broken." + - "Logs: Backend logs include Processing request from person_websocket and Streaming completed." +evidence_required: + - ui + - screenshot + - console + - backend_log +diagnostics: + - "If the UI does not show a Bot response, inspect console/network and backend logs before using API/curl diagnostics." + - "Use API/curl only to distinguish frontend display failure from backend/runtime failure." +success_patterns: + - "Processing request from person_websocket" + - "Streaming completed" +failure_patterns: + - "Action invoke_llm_stream call timed out" + - "Task exception was never retrieved" +troubleshooting: + - debug-chat-history-contaminates-automation + - local-agent-model-route-unavailable + - plugin-runtime-timeout + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/plugin-e2e-smoke.yaml b/skills/skills/langbot-testing/cases/plugin-e2e-smoke.yaml new file mode 100644 index 000000000..87dd27e3f --- /dev/null +++ b/skills/skills/langbot-testing/cases/plugin-e2e-smoke.yaml @@ -0,0 +1,57 @@ +id: plugin-e2e-smoke +title: "Plugin system installs a local plugin and exposes tool/page APIs" +mode: agent-browser +area: plugin +type: smoke +priority: p1 +risk: medium +ci_eligible: false +tags: + - plugin + - runtime + - local-install + - e2e +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_REPO +preconditions: + - "LangBot is started from the target worktree with the SDK under test installed." + - "The active instance is a local test instance where installing or updating the qa-plugin-smoke fixture is acceptable." +steps: + - "Ensure LangBot was started from the target worktree with the SDK under test installed, and use uv run --no-sync so uv does not resync langbot-plugin from the lockfile." + - "Build the fixture plugin at skills/langbot-testing/fixtures/plugins/qa-plugin-smoke using the same local SDK under test." + - "Open LANGBOT_FRONTEND_URL." + - "Log in or initialize a local test account if the clean test instance is not initialized." + - "Navigate to Plugins." + - "Install a local plugin package using the generated qa-plugin-smoke zip file." + - "Wait for the plugin installation task to finish." + - "Confirm the installed plugin list shows QA Plugin Smoke with initialized or running status." + - "Open the plugin detail and confirm the qa_echo Tool, qa_plugin_echo Tool, Prompt Probe EventListener, and Smoke Page components are visible." + - "Open the Smoke Page from the plugin extension page entry if the UI exposes it." + - "Use the page or API diagnostic to call endpoint /ping and confirm the response sentinel qa-plugin-smoke-page." +checks: + - "UI: The plugin installation task finishes successfully and the plugin card appears." + - "UI: Plugin detail shows qa_echo, qa_plugin_echo, Prompt Probe, and Smoke Page components." + - "UI: The plugin extension page renders without a blank iframe or runtime error when reachable from the sidebar." + - "API diagnostic: /api/v1/plugins contains qa/plugin-smoke with status initialized." + - "API diagnostic: /api/v1/tools contains qa_echo and qa_plugin_echo from qa/plugin-smoke." + - "API diagnostic: /api/v1/plugins/qa/plugin-smoke/page-api with page_id smoke and endpoint /ping returns qa-plugin-smoke-page." + - "Logs: Backend logs show Connected to plugin runtime and no plugin action timeout during install/list/page-api." + - "Console: No unexpected frontend errors appear during plugin install or detail navigation." +evidence_required: + - ui + - console + - backend_log + - api_diagnostic +diagnostics: + - "Before trusting results, confirm Python imports langbot_plugin from the local SDK source path, not site-packages from PyPI." + - "If uv run changes the installed SDK back to the lockfile package, reinstall the local SDK and rerun commands with uv run --no-sync." + - "If local install stalls, inspect /api/v1/system/tasks/ and backend logs." +troubleshooting: + - plugin-runtime-timeout + - marketplace-network-flaky + - uv-run-resyncs-local-sdk diff --git a/skills/skills/langbot-testing/cases/provider-deepseek.yaml b/skills/skills/langbot-testing/cases/provider-deepseek.yaml new file mode 100644 index 000000000..eeb0e7d8e --- /dev/null +++ b/skills/skills/langbot-testing/cases/provider-deepseek.yaml @@ -0,0 +1,42 @@ +id: provider-deepseek +title: "DeepSeek provider can be configured and used" +mode: agent-browser +area: provider +type: provider +priority: p2 +risk: medium +ci_eligible: false +tags: + - provider + - model +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +preconditions: + - "A DeepSeek-compatible API key and model route are available from the active secret source and must not be printed in reports." + - "A test pipeline is available for confirming the saved provider can answer Debug Chat." +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Models." + - "Add or edit a DeepSeek provider." + - "Fill the required base URL, API key, and model fields from the active secret source." + - "Run the provider or model test action in the UI." + - "Run a small Pipeline Debug Chat prompt to confirm the provider is usable." +checks: + - "UI: The provider/model test action succeeds in the page." + - "UI: A Pipeline Debug Chat prompt returns a Bot response after provider setup." + - "Console: No unexpected frontend errors appear while saving or testing the provider." + - "Secret safety: No API key, token, or secret is printed in logs or reports." +evidence_required: + - ui + - console + - backend_log +diagnostics: + - "If provider testing fails, inspect backend logs and provider error messages to distinguish invalid credentials from UI wiring issues." + - "Use API/curl only as a diagnostic aid after reproducing through the UI." +troubleshooting: + - proxy-env-mismatch + - plugin-runtime-timeout diff --git a/skills/skills/langbot-testing/cases/qa-plugin-smoke-live-install.yaml b/skills/skills/langbot-testing/cases/qa-plugin-smoke-live-install.yaml new file mode 100644 index 000000000..f0ec3c336 --- /dev/null +++ b/skills/skills/langbot-testing/cases/qa-plugin-smoke-live-install.yaml @@ -0,0 +1,44 @@ +id: qa-plugin-smoke-live-install +title: "QA plugin smoke package installs and exposes tools" +mode: probe +area: plugin +type: regression +priority: p1 +risk: medium +ci_eligible: false +tags: + - plugin + - local-install + - fixture +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_BACKEND_URL + - LANGBOT_REPO + - LANGBOT_E2E_LOGIN_USER +automation: scripts/e2e/install-qa-plugin-smoke.mjs +automation_plugin_package: "skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg" +automation_expected_plugin_id: "qa/plugin-smoke" +automation_expected_tool: "qa_plugin_echo" +steps: + - "Run `rtk bin/lbs test run qa-plugin-smoke-live-install --dry-run` first; remove `--dry-run` only after readiness points at a local test LangBot instance." + - "Automation authenticates the local test user, uploads the QA plugin smoke .lbpkg package when missing, waits for install, and checks tool exposure." +checks: + - "automation-result.json status is pass." + - "/api/v1/plugins lists qa/plugin-smoke after install." + - "/api/v1/tools lists qa_plugin_echo after install." +evidence_required: + - api_diagnostic + - filesystem +diagnostics: + - "This prepares prompt/tool/steering cases that depend on qa-plugin-smoke. It does not prove the model can call the tools." + - "If install fails during dependencies, inspect plugin runtime logs and plugin-dependency-install-offline." +success_patterns: + - "qa/plugin-smoke is installed." +failure_patterns: + - "Plugin install task did not complete successfully" + - "qa_plugin_echo is not listed" +troubleshooting: + - plugin-runtime-timeout + - plugin-dependency-install-offline diff --git a/skills/skills/langbot-testing/cases/sandbox-skill-authoring-e2e.yaml b/skills/skills/langbot-testing/cases/sandbox-skill-authoring-e2e.yaml new file mode 100644 index 000000000..91bb97fd3 --- /dev/null +++ b/skills/skills/langbot-testing/cases/sandbox-skill-authoring-e2e.yaml @@ -0,0 +1,54 @@ +id: sandbox-skill-authoring-e2e +title: "Local Agent creates, registers, activates, and uses a sandbox skill" +mode: agent-browser +area: sandbox +type: regression +priority: p2 +risk: high +ci_eligible: false +tags: + - sandbox + - skills + - local-agent + - tools + - e2b + - nsjail +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +preconditions: + - "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test." + - "LangBot is started with the sandbox backend intended for this run, such as e2b or nsjail." + - "The selected model route supports tool/function calling strongly enough to invoke sandbox tools." +steps: + - "Start LangBot with the target sandbox backend and confirm the Box status UI or LANGBOT_BACKEND_URL /api/v1/box/status reports the expected backend." + - "Open the target Local Agent pipeline in Debug Chat with a function-calling model." + - "Run the canonical prompt pattern in references/sandbox-skill-authoring.md with a unique skill name." + - "Capture the visible final response, backend logs, and the registered skill-store files." +checks: + - "UI: Debug Chat final assistant response contains E2E_OK:." + - "Logs: The model called exec, register_skill, activate, then exec again from the activated skill path." + - "Logs: The selected backend name is the expected one, such as e2b or nsjail." + - "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md." + - "Box status: recent_error_count is 0 after the run." +evidence_required: + - ui + - backend_log + - api_diagnostic + - filesystem +diagnostics: + - "If Debug Chat fails before tool execution, first validate the model provider and function-calling support." + - "If /workspace/.skills/ is missing only on E2B, check extra mount sync behavior." + - "If nsjail starts but every command fails before execve, check nsjail CLI compatibility and chroot mount targets." + - "API or raw provider probes are diagnostic only; they do not make this UI case pass by themselves." +troubleshooting: + - sandbox-native-tools-unavailable + - e2b-extra-mount-sync-missing + - box-session-conflict-logical-metadata + - nsjail-cli-compatibility + - socks-proxy-without-socksio diff --git a/skills/skills/langbot-testing/cases/sandbox-skill-authoring-edit-existing-e2e.yaml b/skills/skills/langbot-testing/cases/sandbox-skill-authoring-edit-existing-e2e.yaml new file mode 100644 index 000000000..af4c383a3 --- /dev/null +++ b/skills/skills/langbot-testing/cases/sandbox-skill-authoring-edit-existing-e2e.yaml @@ -0,0 +1,71 @@ +id: sandbox-skill-authoring-edit-existing-e2e +title: "Local Agent modifies an activated sandbox skill package" +mode: agent-browser +area: sandbox +type: regression +priority: p2 +risk: high +ci_eligible: false +tags: + - sandbox + - skills + - local-agent + - tools + - edit +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_REPO + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_REPO + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_expected_text: "UPDATED_MARKER_EDIT_REGRESSION" +automation_prompts_json: '[{"prompt":"Skill name is lb-skill-edit-regression. Use exactly one exec tool call with workdir=/workspace. In that single command, remove old /workspace/lb-skill-edit-regression, create SKILL.md, scripts/use.py, and data/input.json. The JSON must contain numbers [1,2,3,4], factors [2,3,4], and marker CREATE_MARKER_EDIT_REGRESSION. The Python script must read data/input.json relative to __file__ and print exactly SANDBOX_SKILL_CREATE_OK sum=10 product=24 marker=CREATE_MARKER_EDIT_REGRESSION. Run python3 /workspace/lb-skill-edit-regression/scripts/use.py in the same command. After the exec succeeds, final answer exactly CREATE_OK:lb-skill-edit-regression.","expected_text":"CREATE_MARKER_EDIT_REGRESSION","response_timeout_ms":"180000"},{"prompt":"Continue with the same skill name lb-skill-edit-regression. Strictly call register_skill with path=/workspace/lb-skill-edit-regression and name=lb-skill-edit-regression, then call activate with skill_name=lb-skill-edit-regression. Do not call exec, read, write, edit, grep, or ls in this step. After both tools succeed, final answer exactly REGISTER_ACTIVATE_OK:lb-skill-edit-regression.","expected_text":"REGISTER_ACTIVATE_OK:lb-skill-edit-regression","response_timeout_ms":"180000"},{"prompt":"Continue with the same activated skill lb-skill-edit-regression. Use exactly one exec tool call with workdir=/workspace/.skills/lb-skill-edit-regression. In that single exec command, run exactly this shell logic: set -e; overwrite SKILL.md with a heading and UPDATED_MARKER_EDIT_REGRESSION; overwrite data/input.json with numbers [5,6], factors [7,8], and marker UPDATED_MARKER_EDIT_REGRESSION; overwrite scripts/use.py so it reads data/input.json relative to __file__ and prints exactly SANDBOX_SKILL_MODIFIED_OK sum=11 product=56 marker=UPDATED_MARKER_EDIT_REGRESSION; run python3 scripts/use.py. Do not write exit(1). Do not call find, ls, read, grep as separate tools. If the single exec exits successfully and prints SANDBOX_SKILL_MODIFIED_OK, do not call more tools. Final answer exactly E2E_OK:lb-skill-edit-regression:SANDBOX_SKILL_MODIFIED_OK.","expected_text":"UPDATED_MARKER_EDIT_REGRESSION","response_timeout_ms":"240000"}]' +automation_filesystem_checks_json: '[{"path":"${LANGBOT_REPO}/data/box/skills/lb-skill-edit-regression/SKILL.md","contains":"UPDATED_MARKER_EDIT_REGRESSION"},{"path":"${LANGBOT_REPO}/data/box/skills/lb-skill-edit-regression/data/input.json","contains":["UPDATED_MARKER_EDIT_REGRESSION","\"numbers\": [5, 6]","\"factors\": [7, 8]"]},{"path":"${LANGBOT_REPO}/data/box/skills/lb-skill-edit-regression/scripts/use.py","contains":["SANDBOX_SKILL_MODIFIED_OK"],"not_contains":["SANDBOX_SKILL_CREATE_OK","exit(1)"]},{"argv":["python3","scripts/use.py"],"cwd":"${LANGBOT_REPO}/data/box/skills/lb-skill-edit-regression","stdout_contains":"SANDBOX_SKILL_MODIFIED_OK sum=11 product=56 marker=UPDATED_MARKER_EDIT_REGRESSION","exit_code":0}]' +automation_stream_output: "0" +automation_reset_debug_chat: "1" +automation_response_timeout_ms: "420000" +preconditions: + - "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test." + - "LangBot is started with the sandbox backend intended for this run, such as e2b or nsjail." + - "The selected model route supports tool/function calling strongly enough to invoke sandbox tools." +steps: + - "Start LangBot with the target sandbox backend and confirm native sandbox tools are available." + - "Open the target Local Agent pipeline in Debug Chat with a function-calling model." + - "Run the automation prompt or the equivalent prompt pattern from references/sandbox-skill-authoring.md." + - "Capture the visible final response, backend logs, and the registered skill-store files." +checks: + - "UI: A Bot message, not only the user prompt, contains UPDATED_MARKER_EDIT_REGRESSION." + - "Logs: The model called exec, register_skill, activate, then a second exec whose workdir is /workspace/.skills/lb-skill-edit-regression." + - "Logs: The second exec stdout contains SANDBOX_SKILL_MODIFIED_OK and GREP_ALL_OK or equivalent grep success." + - "Automation: Filesystem checks pass for the registered skill-store files under LANGBOT_REPO/data/box/skills/lb-skill-edit-regression." + - "Skill store: SKILL.md and data/input.json contain UPDATED_MARKER_EDIT_REGRESSION." + - "Skill store: scripts/use.py is the modified script and prints SANDBOX_SKILL_MODIFIED_OK sum=11 product=56 marker=UPDATED_MARKER_EDIT_REGRESSION." +evidence_required: + - ui + - backend_log + - api_diagnostic + - filesystem +diagnostics: + - "If the model stops after activation and only reruns the original script, treat this as a failed edit-existing E2E even if create/register/activate succeeded." + - "If the expected marker appears only in the user prompt, treat the run as failed and inspect automation-result.json assistant counts." + - "If the browser opens /login or console shows 401, refresh the authenticated browser profile before diagnosing the runner." +troubleshooting: + - sandbox-native-tools-unavailable + - e2b-extra-mount-sync-missing + - box-session-conflict-logical-metadata + - nsjail-cli-compatibility + - socks-proxy-without-socksio diff --git a/skills/skills/langbot-testing/cases/webui-login-state.yaml b/skills/skills/langbot-testing/cases/webui-login-state.yaml new file mode 100644 index 000000000..50119b156 --- /dev/null +++ b/skills/skills/langbot-testing/cases/webui-login-state.yaml @@ -0,0 +1,41 @@ +id: webui-login-state +title: "Configured frontend opens with authenticated LangBot WebUI state" +mode: agent-browser +area: auth +type: smoke +priority: p0 +risk: low +ci_eligible: false +tags: + - smoke + - auth +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE +automation: scripts/e2e/webui-login-state.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE +steps: + - "Open LANGBOT_FRONTEND_URL with the configured browser-control path." + - "Confirm the page is not stuck on /login." + - "Check that the sidebar or dashboard shows a logged-in user." + - "If login is missing, use langbot-env-setup OAuth browser profile guidance." +checks: + - "UI: The WebUI shows LangBot navigation such as Dashboard, Bots, Pipelines, or Knowledge." + - "UI: The page is not stuck on /login after the configured profile is loaded." + - "Console: No unexpected frontend errors appear during initial load." + - "Secret safety: The agent does not print token values while checking auth state." +evidence_required: + - ui + - screenshot + - console +diagnostics: + - "If the page stays on /login, inspect only localStorage key names and origin mismatch; do not print token values." +troubleshooting: + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/fixtures/agent-runner/qa-runner-behaviors.json b/skills/skills/langbot-testing/fixtures/agent-runner/qa-runner-behaviors.json new file mode 100644 index 000000000..b2f153321 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/agent-runner/qa-runner-behaviors.json @@ -0,0 +1,38 @@ +{ + "id": "qa-agent-runner-behaviors", + "behaviors": [ + { + "name": "ok", + "results": [ + {"type": "message.completed", "data": {"message": {"role": "assistant", "content": "QA_RUNNER_OK"}}}, + {"type": "run.completed", "data": {"finish_reason": "stop"}} + ] + }, + { + "name": "stream_ok", + "results": [ + {"type": "message.delta", "data": {"chunk": {"role": "assistant", "content": "QA_"}}}, + {"type": "message.delta", "data": {"chunk": {"role": "assistant", "content": "RUNNER_STREAM_OK"}}}, + {"type": "run.completed", "data": {"finish_reason": "stop"}} + ] + }, + { + "name": "empty_output", + "results": [ + {"type": "run.completed", "data": {"finish_reason": "stop"}} + ] + }, + { + "name": "malformed_result", + "results": [ + {"type": "message.completed", "data": {}} + ] + }, + { + "name": "controlled_failure", + "results": [ + {"type": "run.failed", "data": {"error": "QA runner controlled failure", "code": "qa.failure", "retryable": false}} + ] + } + ] +} diff --git a/skills/skills/langbot-testing/fixtures/fixtures.json b/skills/skills/langbot-testing/fixtures/fixtures.json new file mode 100644 index 000000000..70badac14 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/fixtures.json @@ -0,0 +1,93 @@ +[ + { + "id": "qa-agent-runner-behaviors", + "title": "Deterministic AgentRunner behavior matrix", + "kind": "json", + "path": "fixtures/agent-runner/qa-runner-behaviors.json", + "related_cases": [ + "agent-runner-behavior-matrix", + "agent-runner-ledger-invariants", + "agent-runner-runtime-chaos" + ], + "checks": ["exists"] + }, + { + "id": "qa-agent-runner-source", + "title": "QA deterministic AgentRunner fixture source", + "kind": "plugin_source", + "path": "fixtures/plugins/qa-agent-runner/manifest.yaml", + "related_cases": [ + "agent-runner-fixture-contract", + "agent-runner-behavior-matrix", + "agent-runner-live-install", + "agent-runner-qa-debug-chat" + ], + "checks": ["exists", "qa_agent_runner_source"] + }, + { + "id": "qa-agent-runner-package", + "title": "QA deterministic AgentRunner prebuilt package", + "kind": "plugin_package", + "path": "fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg", + "related_cases": [ + "agent-runner-fixture-contract", + "agent-runner-live-install", + "agent-runner-qa-debug-chat" + ], + "checks": ["exists", "zip_package"] + }, + { + "id": "mcp-stdio-echo-server", + "title": "MCP stdio qa_mcp_echo server", + "kind": "python", + "path": "fixtures/mcp/qa_mcp_echo_server.py", + "related_cases": ["mcp-stdio-tool-call"], + "checks": ["exists", "direct_mcp_script", "langbot_registration_script"] + }, + { + "id": "rag-sentinel-doc", + "title": "LangRAG sentinel text document", + "kind": "text", + "path": "fixtures/rag/sentinel-doc.txt", + "related_cases": ["langrag-kb-retrieve", "local-agent-rag-debug-chat"], + "checks": ["exists"] + }, + { + "id": "rag-parser-golden-html", + "title": "LangRAG parser golden HTML document", + "kind": "html", + "path": "fixtures/rag/parser-golden.html", + "related_cases": ["langrag-parser-golden-e2e"], + "checks": ["exists"] + }, + { + "id": "multimodal-red-square", + "title": "64x64 red-square image fixture", + "kind": "base64_png", + "path": "fixtures/multimodal/red-square.png.base64", + "related_cases": ["local-agent-multimodal-debug-chat", "local-agent-rag-multimodal-debug-chat"], + "checks": ["exists"] + }, + { + "id": "qa-plugin-smoke-source", + "title": "QA plugin smoke fixture source", + "kind": "plugin_source", + "path": "fixtures/plugins/qa-plugin-smoke/manifest.yaml", + "related_cases": [ + "qa-plugin-smoke-live-install", + "plugin-e2e-smoke", + "local-agent-effective-prompt-debug-chat", + "local-agent-plugin-tool-call-debug-chat", + "local-agent-steering-debug-chat" + ], + "checks": ["exists"] + }, + { + "id": "qa-plugin-smoke-package", + "title": "QA plugin smoke prebuilt package", + "kind": "plugin_package", + "path": "fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg", + "related_cases": ["qa-plugin-smoke-live-install", "plugin-e2e-smoke"], + "checks": ["exists", "zip_package"] + } +] diff --git a/skills/skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py b/skills/skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py new file mode 100644 index 000000000..44e04b8e8 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json +import sys +import typing + +SERVER_INFO = {"name": "langbot-qa-stdio", "version": "0.1.0"} +TOOL_NAME = "qa_mcp_echo" + + +def _write_message(message: dict[str, typing.Any]) -> None: + sys.stdout.write( + json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n" + ) + sys.stdout.flush() + + +def _result( + message_id: typing.Any, result: dict[str, typing.Any] +) -> dict[str, typing.Any]: + return {"jsonrpc": "2.0", "id": message_id, "result": result} + + +def _error(message_id: typing.Any, code: int, message: str) -> dict[str, typing.Any]: + return { + "jsonrpc": "2.0", + "id": message_id, + "error": { + "code": code, + "message": message, + }, + } + + +def _tool_schema() -> dict[str, typing.Any]: + return { + "name": TOOL_NAME, + "description": "Return a deterministic QA echo string.", + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Text to include in the QA echo response.", + }, + }, + "required": ["text"], + "additionalProperties": False, + }, + } + + +def handle_message(message: dict[str, typing.Any]) -> dict[str, typing.Any] | None: + message_id = message.get("id") + method = str(message.get("method") or "") + params = message.get("params") or {} + if not isinstance(params, dict): + params = {} + + if message_id is None: + return None + + if method == "initialize": + return _result( + message_id, + { + "protocolVersion": str(params.get("protocolVersion") or "2024-11-05"), + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": SERVER_INFO, + }, + ) + + if method == "ping": + return _result(message_id, {}) + + if method == "tools/list": + return _result(message_id, {"tools": [_tool_schema()]}) + + if method == "tools/call": + name = str(params.get("name") or "") + arguments = params.get("arguments") or {} + if not isinstance(arguments, dict): + arguments = {} + if name != TOOL_NAME: + return _error(message_id, -32602, f"Unknown tool: {name}") + text = str(arguments.get("text") or "") + return _result( + message_id, + { + "content": [ + { + "type": "text", + "text": f"{TOOL_NAME}:{text}", + } + ], + "isError": False, + }, + ) + + return _error(message_id, -32601, f"Method not found: {method}") + + +def main() -> int: + for raw_line in sys.stdin: + line = raw_line.strip() + if not line: + continue + try: + message = json.loads(line) + except json.JSONDecodeError as exc: + _write_message(_error(None, -32700, f"Parse error: {exc}")) + continue + if not isinstance(message, dict): + _write_message(_error(None, -32600, "Invalid request")) + continue + response = handle_message(message) + if response is not None: + _write_message(response) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/skills/langbot-testing/fixtures/multimodal/red-pixel.png.base64 b/skills/skills/langbot-testing/fixtures/multimodal/red-pixel.png.base64 new file mode 100644 index 000000000..c8e6472e9 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/multimodal/red-pixel.png.base64 @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADUlEQVR4nGP4z8DwHwAFAAH/e+m+7wAAAABJRU5ErkJggg== diff --git a/skills/skills/langbot-testing/fixtures/multimodal/red-square.png.base64 b/skills/skills/langbot-testing/fixtures/multimodal/red-square.png.base64 new file mode 100644 index 000000000..808a8341a --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/multimodal/red-square.png.base64 @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAT0lEQVR42u3PQQkAAAgEsIty/TMZxgi+hcEKLNO+FgEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQGBywIJs8EAKp/R7QAAAABJRU5ErkJggg== diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/README.md b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/README.md new file mode 100644 index 000000000..cbde4cc52 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/README.md @@ -0,0 +1,15 @@ +# QA AgentRunner Fixture + +Deterministic AgentRunner plugin source used by `langbot-skills` probes and future browser release-gate cases. + +Runner id after installation should be: + +```text +plugin:qa/agent-runner/default +``` + +Expected behavior: + +- normal input returns `QA_AGENT_RUNNER_OK:` +- input containing `stream` emits streaming chunks then completes +- input containing `fail` returns `QA_AGENT_RUNNER_CONTROLLED_FAILURE` diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/assets/icon.svg b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/assets/icon.svg new file mode 100644 index 000000000..2a0e899bb --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/assets/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.py b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.py new file mode 100644 index 000000000..7d2fd37dc --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import typing + +from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner +from langbot_plugin.api.entities.builtin.agent_runner import AgentRunContext, AgentRunResult +from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk + + +class DefaultAgentRunner(AgentRunner): + async def run( + self, + ctx: AgentRunContext, + ) -> typing.AsyncGenerator[AgentRunResult, None]: + text = (ctx.input.to_text() or "").strip() + if "fail" in text.lower(): + yield AgentRunResult.run_failed( + ctx.run_id, + error="QA_AGENT_RUNNER_CONTROLLED_FAILURE", + code="qa.controlled_failure", + retryable=False, + ) + return + + content = f"QA_AGENT_RUNNER_OK:{text or 'empty'}" + if "stream" in text.lower(): + for chunk in ("QA_", "AGENT_", f"RUNNER_OK:{text}"): + yield AgentRunResult.message_delta( + ctx.run_id, + MessageChunk(role="assistant", content=chunk), + ) + yield AgentRunResult.run_completed(ctx.run_id, finish_reason="stop") + return + + yield AgentRunResult.run_completed( + ctx.run_id, + Message(role="assistant", content=content), + finish_reason="stop", + ) diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.yaml new file mode 100644 index 000000000..a2fd64d98 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.yaml @@ -0,0 +1,30 @@ +apiVersion: langbot/v1 +kind: AgentRunner +metadata: + name: default + label: + en_US: QA Deterministic Runner + zh_Hans: QA 确定性 Runner + description: + en_US: Deterministic runner fixture that returns stable QA sentinel output. + zh_Hans: 返回稳定 QA 哨兵输出的确定性 runner 夹具。 +spec: + capabilities: + streaming: true + tool_calling: false + knowledge_retrieval: false + multimodal_input: false + skill_authoring: false + interrupt: false + permissions: + models: [] + tools: [] + knowledge_bases: [] + history: [] + events: [] + storage: [] + config: [] +execution: + python: + path: default.py + attr: DefaultAgentRunner diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg new file mode 100644 index 000000000..aba6ead2d Binary files /dev/null and b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg differ diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/main.py b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/main.py new file mode 100644 index 000000000..6e1c392d2 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/main.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from langbot_plugin.api.definition.plugin import BasePlugin + + +class QAAgentRunnerPlugin(BasePlugin): + async def initialize(self) -> None: + self.ready_marker = "qa-agent-runner-ready" diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/manifest.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/manifest.yaml new file mode 100644 index 000000000..a7fd08cac --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/manifest.yaml @@ -0,0 +1,25 @@ +apiVersion: langbot/v1 +kind: Plugin +metadata: + author: qa + name: agent-runner + repository: https://example.invalid/langbot/qa-agent-runner + version: 0.1.0 + description: + en_US: Deterministic AgentRunner fixture for LangBot QA. + zh_Hans: LangBot QA 使用的确定性 AgentRunner 夹具。 + label: + en_US: QA AgentRunner + zh_Hans: QA AgentRunner + icon: assets/icon.svg +spec: + config: [] + components: + AgentRunner: + fromDirs: + - path: components/agent_runner/ + maxDepth: 1 +execution: + python: + path: main.py + attr: QAAgentRunnerPlugin diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/.gitignore b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/.gitignore new file mode 100644 index 000000000..89d8e500c --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/.gitignore @@ -0,0 +1,3 @@ +dist/* +!dist/ +!dist/qa-plugin-smoke-0.1.0.lbpkg diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/README.md b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/README.md new file mode 100644 index 000000000..e276f5774 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/README.md @@ -0,0 +1,9 @@ +# QA Plugin Smoke + +Local fixture plugin for LangBot plugin E2E smoke testing. + +Tools: + +- `qa_echo(text)` returns `qa-plugin-smoke:`. +- `qa_plugin_echo(text)` returns `qa-plugin-smoke:`. +- `qa_plugin_sleep(seconds, text)` waits up to 15 seconds and returns `qa-plugin-smoke:sleep::`. diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/assets/icon.svg b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/assets/icon.svg new file mode 100644 index 000000000..440e9c82c --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/assets/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/events/prompt_probe.py b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/events/prompt_probe.py new file mode 100644 index 000000000..c2b06d0e3 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/events/prompt_probe.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from typing import Any + +from langbot_plugin.api.definition.components.common.event_listener import EventListener +from langbot_plugin.api.entities import context, events +from langbot_plugin.api.entities.builtin.provider.message import Message + + +def _content_text(content: Any) -> str: + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + + parts: list[str] = [] + for item in content: + text = item.get("text") if isinstance(item, dict) else getattr(item, "text", None) + if text: + parts.append(str(text)) + return "".join(parts) + + +def _message_text(message: Any) -> str: + if message is None: + return "" + return _content_text(getattr(message, "content", None)) + + +def _message_chain_text(message_chain: Any) -> str: + if message_chain is None: + return "" + try: + return str(message_chain) + except Exception: + return "" + + +async def _current_user_text(event_context: context.EventContext) -> str: + try: + query_var_text = await event_context.get_query_var("user_message_text") + except Exception: + query_var_text = None + if query_var_text: + return str(query_var_text) + + query = getattr(event_context.event, "query", None) + if query is not None: + message_chain_text = _message_chain_text(getattr(query, "message_chain", None)) + if message_chain_text: + return message_chain_text + + user_message_text = _message_text(getattr(query, "user_message", None)) + if user_message_text: + return user_message_text + + return "\n".join( + text for text in (_message_text(message) for message in event_context.event.prompt) if text + ) + + +class PromptProbeEventListener(EventListener): + async def initialize(self) -> None: + await super().initialize() + + @self.handler(events.PromptPreProcessing) + async def on_prompt_pre_processing(event_context: context.EventContext) -> None: + if "qa-effective-prompt" not in await _current_user_text(event_context): + return + + event_context.event.default_prompt.append( + Message( + role="system", + content=( + "QA prompt probe: if the current user message contains " + "qa-effective-prompt, reply only PROMPT_PREPROCESS_OK." + ), + ) + ) diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/events/prompt_probe.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/events/prompt_probe.yaml new file mode 100644 index 000000000..1e1cf0f9b --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/events/prompt_probe.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: EventListener +metadata: + name: prompt_probe + label: + en_US: Prompt Probe + zh_Hans: Prompt 探针 +spec: +execution: + python: + path: prompt_probe.py + attr: PromptProbeEventListener diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/i18n/en_US.json b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/i18n/en_US.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/i18n/en_US.json @@ -0,0 +1 @@ +{} diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/i18n/zh_Hans.json b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/i18n/zh_Hans.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/i18n/zh_Hans.json @@ -0,0 +1 @@ +{} diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/index.html b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/index.html new file mode 100644 index 000000000..f7a9ad289 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/index.html @@ -0,0 +1,43 @@ + + + + + + Smoke Page + + + +
+

Smoke Page

+

qa-plugin-smoke-page

+

waiting

+
+ + + + diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/smoke.py b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/smoke.py new file mode 100644 index 000000000..4e6d2625e --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/smoke.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from langbot_plugin.api.definition.components.page import Page, PageRequest, PageResponse + + +class SmokePage(Page): + async def handle_api(self, request: PageRequest) -> PageResponse: + return PageResponse.ok( + { + "sentinel": "qa-plugin-smoke-page", + "endpoint": request.endpoint, + "method": request.method, + "body": request.body, + } + ) diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/smoke.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/smoke.yaml new file mode 100644 index 000000000..83c9eb772 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/smoke.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Page +metadata: + name: smoke + label: + en_US: Smoke Page + zh_Hans: 冒烟测试页 + description: + en_US: Page component for plugin e2e smoke testing. + zh_Hans: 插件端到端冒烟测试页面组件。 +spec: + path: index.html +execution: + python: + path: smoke.py + attr: SmokePage diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_echo.py b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_echo.py new file mode 100644 index 000000000..69e0fb2d1 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_echo.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Any + +from langbot_plugin.api.definition.components.tool.tool import Tool +from langbot_plugin.api.entities.builtin.provider import session as provider_session + + +class QAEchoTool(Tool): + async def call( + self, + params: dict[str, Any], + session: provider_session.Session, + query_id: int, + ) -> str: + text = str(params.get("text", "")) + return f"qa-plugin-smoke:{text}" diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_echo.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_echo.yaml new file mode 100644 index 000000000..277594743 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_echo.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Tool +metadata: + name: qa_echo + label: + en_US: QA Echo + zh_Hans: QA 回显 + description: + en_US: Echoes deterministic text for plugin smoke testing. + zh_Hans: 为插件冒烟测试回显确定性文本。 +spec: + parameters: + type: object + properties: + text: + type: string + description: Text to echo. + required: + - text + llm_prompt: Echo deterministic text for plugin smoke testing. +execution: + python: + path: qa_echo.py + attr: QAEchoTool diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_echo.py b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_echo.py new file mode 100644 index 000000000..343218046 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_echo.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Any + +from langbot_plugin.api.definition.components.tool.tool import Tool +from langbot_plugin.api.entities.builtin.provider import session as provider_session + + +class QAPluginEchoTool(Tool): + async def call( + self, + params: dict[str, Any], + session: provider_session.Session, + query_id: int, + ) -> str: + text = str(params.get("text", "")) + return f"qa-plugin-smoke:{text}" diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_echo.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_echo.yaml new file mode 100644 index 000000000..73ea93e10 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_echo.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Tool +metadata: + name: qa_plugin_echo + label: + en_US: QA Plugin Echo + zh_Hans: QA 插件回显 + description: + en_US: Echoes deterministic text with a plugin-specific tool name. + zh_Hans: 使用插件专属工具名回显确定性文本。 +spec: + parameters: + type: object + properties: + text: + type: string + description: Text to echo. + required: + - text + llm_prompt: Echo deterministic text with qa-plugin-smoke prefix for plugin tool testing. +execution: + python: + path: qa_plugin_echo.py + attr: QAPluginEchoTool diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_sleep.py b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_sleep.py new file mode 100644 index 000000000..1d0d97dd7 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_sleep.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from langbot_plugin.api.definition.components.tool.tool import Tool +from langbot_plugin.api.entities.builtin.provider import session as provider_session + + +class QAPluginSleepTool(Tool): + async def call( + self, + params: dict[str, Any], + session: provider_session.Session, + query_id: int, + ) -> str: + raw_seconds = params.get("seconds", 0) + try: + seconds = float(raw_seconds) + except (TypeError, ValueError): + seconds = 0.0 + seconds = max(0.0, min(seconds, 15.0)) + text = str(params.get("text", "")) + await asyncio.sleep(seconds) + seconds_label = str(int(seconds)) if seconds.is_integer() else str(seconds) + return f"qa-plugin-smoke:sleep:{seconds_label}:{text}" diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_sleep.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_sleep.yaml new file mode 100644 index 000000000..6bc9bc9dc --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_sleep.yaml @@ -0,0 +1,28 @@ +apiVersion: v1 +kind: Tool +metadata: + name: qa_plugin_sleep + label: + en_US: QA Plugin Sleep + zh_Hans: QA 插件延迟 + description: + en_US: Sleeps for a bounded number of seconds and returns deterministic text. + zh_Hans: 延迟指定秒数后返回确定性文本。 +spec: + parameters: + type: object + properties: + seconds: + type: number + description: Seconds to sleep, clamped to the 0-15 range. + text: + type: string + description: Text to include in the deterministic result. + required: + - seconds + - text + llm_prompt: Sleep for a bounded duration and return qa-plugin-smoke sleep text for steering tests. +execution: + python: + path: qa_plugin_sleep.py + attr: QAPluginSleepTool diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg new file mode 100644 index 000000000..a4a50f803 Binary files /dev/null and b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg differ diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/main.py b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/main.py new file mode 100644 index 000000000..e2a50d3e6 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/main.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from langbot_plugin.api.definition.plugin import BasePlugin + + +class PluginSmoke(BasePlugin): + async def initialize(self) -> None: + self.ready_marker = "qa-plugin-smoke-ready" diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/manifest.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/manifest.yaml new file mode 100644 index 000000000..b5a0f55c4 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/manifest.yaml @@ -0,0 +1,33 @@ +apiVersion: v1 +kind: Plugin +metadata: + author: qa + name: plugin-smoke + repository: https://example.invalid/langbot/qa-plugin-smoke + version: 0.1.0 + description: + en_US: Local fixture plugin for LangBot plugin e2e smoke tests. + zh_Hans: LangBot 插件端到端冒烟测试用本地夹具插件。 + label: + en_US: QA Plugin Smoke + zh_Hans: QA 插件冒烟测试 + icon: assets/icon.svg +spec: + config: [] + components: + EventListener: + fromDirs: + - path: components/events/ + maxDepth: 1 + Tool: + fromDirs: + - path: components/tools/ + maxDepth: 1 + Page: + fromDirs: + - path: components/pages/ + maxDepth: 2 +execution: + python: + path: main.py + attr: PluginSmoke diff --git a/skills/skills/langbot-testing/fixtures/rag/parser-golden.html b/skills/skills/langbot-testing/fixtures/rag/parser-golden.html new file mode 100644 index 000000000..a684f9307 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/rag/parser-golden.html @@ -0,0 +1,32 @@ + + + + + LangRAG GeneralParsers Golden Fixture + + +
+
+

Parser Golden Case

+

The parser-rag golden sentinel is aurora-parser-rag-9137.

+
+ + + + + + + + + + + + + +
Parser fieldGolden value
parser_pluginGeneralParsers
knowledge_engineLangRAG
+
+

Retrieve tests should return aurora-parser-rag-9137 with GeneralParsers and LangRAG.

+
+
+ + diff --git a/skills/skills/langbot-testing/fixtures/rag/sentinel-doc.txt b/skills/skills/langbot-testing/fixtures/rag/sentinel-doc.txt new file mode 100644 index 000000000..310d6c1a2 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/rag/sentinel-doc.txt @@ -0,0 +1,6 @@ +LangBot QA test document. + +The local agent runner retrieval sentinel is azalea-cobalt-7421. +When asked about the sentinel, answer with exactly azalea-cobalt-7421. + +This document is intentionally small so LangRAG ingestion and retrieval can be tested quickly. diff --git a/skills/skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs b/skills/skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs new file mode 100755 index 000000000..3bf1facf0 --- /dev/null +++ b/skills/skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { env } from "node:process"; + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + const pad = (value) => String(value).padStart(2, "0"); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function run(command, timeoutMs, childEnv) { + return new Promise((resolveDone) => { + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + detached: true, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + }, timeoutMs); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null }); + }); + child.on("close", (status, signal) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error: null, timedOut, status, signal }); + }); + }); +} + +const script = ` +import asyncio +import aiosqlite + +async def main(): + async with aiosqlite.connect(':memory:') as db: + await db.execute('create table t(id integer primary key)') + await db.commit() + print('AIOSQLITE_READY') + +asyncio.run(main()) +`; + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "agent-runner-async-db-readiness"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + const startedAt = new Date(); + const langbotRepo = resolve(root, env.LANGBOT_REPO || "../LangBot"); + const stdoutLog = join(evidenceDir, "probe-stdout.log"); + const stderrLog = join(evidenceDir, "probe-stderr.log"); + const automationResultJson = join(evidenceDir, "automation-result.json"); + const resultJson = join(evidenceDir, "result.json"); + const timeoutMs = Number(env.LANGBOT_ASYNC_DB_READINESS_TIMEOUT_MS || "5000"); + const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script], cwd: langbotRepo }; + const result = { + source: "automation", + probe: "aiosqlite-readiness", + case_id: caseId, + run_id: runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + status: "fail", + reason: "", + repo_path: langbotRepo, + command, + timeout_ms: timeoutMs, + exit_status: null, + signal: null, + evidence: { stdout_log: stdoutLog, stderr_log: stderrLog, automation_result_json: automationResultJson, result_json: resultJson }, + evidence_collected: ["filesystem"], + }; + try { + if (!existsSync(langbotRepo)) { + result.status = "env_issue"; + result.reason = `LANGBOT_REPO/default ../LangBot did not resolve: ${langbotRepo}`; + } else { + const proc = await run(command, timeoutMs, { + ...process.env, + UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"), + }); + await writeFile(stdoutLog, proc.stdout, "utf8"); + await writeFile(stderrLog, proc.stderr, "utf8"); + result.exit_status = proc.status; + result.signal = proc.signal; + if (proc.error) { + result.status = "env_issue"; + result.reason = proc.error.message; + } else if (proc.timedOut) { + result.status = "env_issue"; + result.reason = `aiosqlite readiness timed out after ${timeoutMs}ms`; + } else if (proc.status === 0 && proc.stdout.includes("AIOSQLITE_READY")) { + result.status = "pass"; + result.reason = "aiosqlite readiness passed"; + } else { + result.status = "env_issue"; + result.reason = `aiosqlite readiness exited with status ${proc.status}`; + } + } + } catch (error) { + result.status = "env_issue"; + result.reason = error instanceof Error ? error.message : String(error); + } finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultJson, resultText, "utf8"); + await writeFile(resultJson, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } + process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs b/skills/skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs new file mode 100755 index 000000000..bc7ded87b --- /dev/null +++ b/skills/skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; +import { env } from "node:process"; + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + const pad = (value) => String(value).padStart(2, "0"); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function run(command, timeoutMs, childEnv) { + return new Promise((resolveDone) => { + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + detached: true, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + }, timeoutMs); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null }); + }); + child.on("close", (status, signal) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error: null, timedOut, status, signal }); + }); + }); +} + +const script = String.raw` +import asyncio +import json +import sys +from pathlib import Path + +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.errors import RunnerExecutionError, RunnerProtocolError +from langbot.pkg.agent.runner.result_normalizer import AgentResultNormalizer + +class Logger: + def debug(self, *_args, **_kwargs): pass + def info(self, *_args, **_kwargs): pass + def warning(self, *_args, **_kwargs): pass + def error(self, *_args, **_kwargs): pass + +class App: + logger = Logger() + +def descriptor(): + return AgentRunnerDescriptor( + id='plugin:qa/agent-runner/default', + source='plugin', + label={'en_US': 'QA AgentRunner'}, + plugin_author='qa', + plugin_name='agent-runner', + runner_name='default', + capabilities={'streaming': True}, + ) + +async def consume_behavior(normalizer, desc, behavior): + messages = [] + chunks = [] + failures = [] + protocol_errors = [] + for result in behavior['results']: + try: + normalized = await normalizer.normalize(result, desc) + except RunnerExecutionError as exc: + failures.append(str(exc)) + continue + except RunnerProtocolError as exc: + protocol_errors.append(str(exc)) + continue + if normalized is None: + continue + content = getattr(normalized, 'content', '') + if normalized.__class__.__name__ == 'MessageChunk': + chunks.append(content) + else: + messages.append(content) + return { + 'name': behavior['name'], + 'messages': messages, + 'chunks': chunks, + 'failures': failures, + 'protocol_errors': protocol_errors, + } + +async def main(path): + data = json.loads(Path(path).read_text()) + normalizer = AgentResultNormalizer(App()) + desc = descriptor() + observed = [await consume_behavior(normalizer, desc, item) for item in data['behaviors']] + by_name = {item['name']: item for item in observed} + assert by_name['ok']['messages'] == ['QA_RUNNER_OK'], by_name['ok'] + assert ''.join(by_name['stream_ok']['chunks']) == 'QA_RUNNER_STREAM_OK', by_name['stream_ok'] + assert by_name['empty_output']['messages'] == [] and by_name['empty_output']['chunks'] == [], by_name['empty_output'] + assert by_name['malformed_result']['messages'] == [] and by_name['malformed_result']['chunks'] == [], by_name['malformed_result'] + assert by_name['controlled_failure']['failures'], by_name['controlled_failure'] + print('QA_RUNNER_BEHAVIOR_MATRIX_OK behaviors=%d' % len(observed)) + +asyncio.run(main(sys.argv[1])) +`; + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "agent-runner-behavior-matrix"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + const startedAt = new Date(); + const langbotRepo = resolve(root, env.LANGBOT_REPO || "../LangBot"); + const sdkSrc = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk/src"); + const fixturePath = resolve(root, "skills/langbot-testing/fixtures/agent-runner/qa-runner-behaviors.json"); + const stdoutLog = join(evidenceDir, "probe-stdout.log"); + const stderrLog = join(evidenceDir, "probe-stderr.log"); + const automationResultJson = join(evidenceDir, "automation-result.json"); + const resultJson = join(evidenceDir, "result.json"); + const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); + const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script, fixturePath], cwd: langbotRepo }; + const result = { + source: "automation", + probe: "agent-runner-behavior-matrix", + case_id: caseId, + run_id: runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + status: "fail", + reason: "", + fixture_path: fixturePath, + repo_path: langbotRepo, + python_paths: [sdkSrc], + command, + timeout_ms: timeoutMs, + exit_status: null, + signal: null, + evidence: { stdout_log: stdoutLog, stderr_log: stderrLog, automation_result_json: automationResultJson, result_json: resultJson }, + evidence_collected: ["filesystem"], + }; + try { + if (!existsSync(langbotRepo) || !existsSync(fixturePath)) { + result.status = "env_issue"; + result.reason = `missing repo or fixture: ${langbotRepo} ${fixturePath}`; + } else { + const proc = await run(command, timeoutMs, { + ...process.env, + PYTHONPATH: [sdkSrc, process.env.PYTHONPATH].filter(Boolean).join(delimiter), + UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"), + }); + await writeFile(stdoutLog, proc.stdout, "utf8"); + await writeFile(stderrLog, proc.stderr, "utf8"); + result.exit_status = proc.status; + result.signal = proc.signal; + if (proc.error) { + result.status = "env_issue"; + result.reason = proc.error.message; + } else if (proc.timedOut) { + result.status = "fail"; + result.reason = `behavior matrix timed out after ${timeoutMs}ms`; + } else if (proc.status === 0 && proc.stdout.includes("QA_RUNNER_BEHAVIOR_MATRIX_OK")) { + result.status = "pass"; + result.reason = "behavior matrix passed"; + } else { + result.status = "fail"; + result.reason = `behavior matrix exited with status ${proc.status}`; + } + } + } catch (error) { + result.status = "fail"; + result.reason = error instanceof Error ? error.message : String(error); + } finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultJson, resultText, "utf8"); + await writeFile(resultJson, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } + process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/agent-runner-fixture-contract.mjs b/skills/skills/langbot-testing/probes/agent-runner-fixture-contract.mjs new file mode 100755 index 000000000..00c447f71 --- /dev/null +++ b/skills/skills/langbot-testing/probes/agent-runner-fixture-contract.mjs @@ -0,0 +1,206 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; +import { env } from "node:process"; + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + const pad = (value) => String(value).padStart(2, "0"); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function run(command, timeoutMs, childEnv) { + return new Promise((resolveDone) => { + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + detached: true, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + }, timeoutMs); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null }); + }); + child.on("close", (status, signal) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error: null, timedOut, status, signal }); + }); + }); +} + +const script = String.raw` +import asyncio +import importlib.util +import sys +from pathlib import Path + +from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext +from langbot_plugin.api.entities.builtin.agent_runner.event import AgentEventContext +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources +from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext +from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger + +fixture = Path(sys.argv[1]) +runner_py = fixture / "components" / "agent_runner" / "default.py" +manifest = fixture / "manifest.yaml" +runner_yaml = fixture / "components" / "agent_runner" / "default.yaml" +assert manifest.exists(), manifest +assert runner_yaml.exists(), runner_yaml +spec = importlib.util.spec_from_file_location("qa_agent_runner_fixture", runner_py) +module = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(module) + +def context(run_id, text): + return AgentRunContext( + run_id=run_id, + trigger=AgentTrigger(type="message.received", source="webui"), + event=AgentEventContext(event_id=f"evt-{run_id}", event_type="message.received", source="webui"), + input=AgentInput(text=text), + delivery=DeliveryContext(surface="debug_chat"), + resources=AgentResources(), + runtime=AgentRuntimeContext(langbot_version="qa"), + ) + +async def collect(text): + runner = module.DefaultAgentRunner() + results = [] + async for result in runner.run(context(f"run-{len(text)}", text)): + results.append(result) + return results + +async def main(): + normal = await collect("hello") + assert len(normal) == 1, normal + assert normal[0].type.value == "run.completed" + assert normal[0].data["message"]["content"] == "QA_AGENT_RUNNER_OK:hello" + + stream = await collect("stream hello") + assert [item.type.value for item in stream] == ["message.delta", "message.delta", "message.delta", "run.completed"] + assert "".join(item.data["chunk"]["content"] for item in stream[:3]) == "QA_AGENT_RUNNER_OK:stream hello" + + failed = await collect("please fail") + assert len(failed) == 1 + assert failed[0].type.value == "run.failed" + assert failed[0].data["error"] == "QA_AGENT_RUNNER_CONTROLLED_FAILURE" + print("QA_AGENT_RUNNER_FIXTURE_CONTRACT_OK") + +asyncio.run(main()) +`; + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "agent-runner-fixture-contract"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + const startedAt = new Date(); + const sdkRepo = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk"); + const sdkSrc = resolve(sdkRepo, "src"); + const fixturePath = resolve(root, "skills/langbot-testing/fixtures/plugins/qa-agent-runner"); + const stdoutLog = join(evidenceDir, "probe-stdout.log"); + const stderrLog = join(evidenceDir, "probe-stderr.log"); + const automationResultJson = join(evidenceDir, "automation-result.json"); + const resultJson = join(evidenceDir, "result.json"); + const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); + const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script, fixturePath], cwd: sdkRepo }; + const result = { + source: "automation", + probe: "agent-runner-fixture-contract", + case_id: caseId, + run_id: runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + status: "fail", + reason: "", + repo_path: sdkRepo, + fixture_path: fixturePath, + command, + timeout_ms: timeoutMs, + exit_status: null, + signal: null, + evidence: { stdout_log: stdoutLog, stderr_log: stderrLog, automation_result_json: automationResultJson, result_json: resultJson }, + evidence_collected: ["filesystem"], + }; + try { + if (!existsSync(sdkRepo) || !existsSync(fixturePath)) { + result.status = "env_issue"; + result.reason = `SDK repo or fixture path missing: ${sdkRepo} ${fixturePath}`; + } else { + const proc = await run(command, timeoutMs, { + ...process.env, + PYTHONPATH: [sdkSrc, process.env.PYTHONPATH].filter(Boolean).join(delimiter), + UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"), + }); + await writeFile(stdoutLog, proc.stdout, "utf8"); + await writeFile(stderrLog, proc.stderr, "utf8"); + result.exit_status = proc.status; + result.signal = proc.signal; + if (proc.error) { + result.status = "env_issue"; + result.reason = proc.error.message; + } else if (proc.timedOut) { + result.status = "fail"; + result.reason = `fixture contract probe timed out after ${timeoutMs}ms`; + } else if (proc.status === 0 && proc.stdout.includes("QA_AGENT_RUNNER_FIXTURE_CONTRACT_OK")) { + result.status = "pass"; + result.reason = "QA AgentRunner fixture contract passed"; + } else { + result.status = "fail"; + result.reason = `fixture contract exited with status ${proc.status}`; + } + } + } catch (error) { + result.status = "fail"; + result.reason = error instanceof Error ? error.message : String(error); + } finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultJson, resultText, "utf8"); + await writeFile(resultJson, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } + process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs b/skills/skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs new file mode 100755 index 000000000..f6c98389a --- /dev/null +++ b/skills/skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +import { runPytestProbe } from "./pytest-probe.mjs"; + +await runPytestProbe({ + caseId: "agent-runner-ledger-concurrency", + repoEnvKey: "LANGBOT_REPO", + defaultRepo: "../LangBot", + pythonPathEnvKeys: ["LANGBOT_PLUGIN_SDK_REPO"], + defaultPythonPaths: ["../langbot-plugin-sdk/src"], + description: "LangBot AgentRunner run ledger claim, lease, authorization, and runtime-admin pytest probe.", + testTargets: [ + "tests/unit_tests/agent/test_run_ledger_store.py::test_create_queued_run_claim_renew_release", + "tests/unit_tests/agent/test_run_ledger_store.py::test_expired_claim_can_be_reclaimed", + "tests/unit_tests/agent/test_run_ledger_api_auth.py::test_runtime_admin_can_register_list_and_claim_with_own_run_session", + "tests/unit_tests/agent/test_run_ledger_api_auth.py::test_run_append_result_basic_path", + "tests/unit_tests/agent/test_run_ledger_api_auth.py::test_run_finalize_basic_path", + "tests/unit_tests/agent/test_run_ledger_api_auth.py::test_run_claim_renew_and_release_actions", + ], +}); diff --git a/skills/skills/langbot-testing/probes/agent-runner-ledger-contention.mjs b/skills/skills/langbot-testing/probes/agent-runner-ledger-contention.mjs new file mode 100755 index 000000000..ec4670011 --- /dev/null +++ b/skills/skills/langbot-testing/probes/agent-runner-ledger-contention.mjs @@ -0,0 +1,230 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; +import { env } from "node:process"; + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + const pad = (value) => String(value).padStart(2, "0"); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function run(command, timeoutMs, childEnv) { + return new Promise((resolveDone) => { + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + detached: true, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + }, timeoutMs); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null }); + }); + child.on("close", (status, signal) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error: null, timedOut, status, signal }); + }); + }); +} + +const script = String.raw` +import concurrent.futures +import sqlite3 +import sys +import time +from pathlib import Path + +from sqlalchemy import create_engine + +from langbot.pkg.entity.persistence.agent_run import AgentRun, AgentRunEvent, AgentRuntime + +db_path = Path(sys.argv[1]) +run_count = 120 +worker_count = 8 +engine = create_engine(f"sqlite:///{db_path}") +for table in (AgentRun.__table__, AgentRunEvent.__table__, AgentRuntime.__table__): + table.create(engine) + +with engine.begin() as conn: + conn.execute(AgentRun.__table__.insert(), [ + { + "run_id": f"run-{i:03d}", + "event_id": f"evt-{i:03d}", + "binding_id": "binding-contention", + "runner_id": "plugin:qa/agent-runner/default", + "status": "queued", + "queue_name": "default", + "priority": run_count - i, + } + for i in range(run_count) + ]) + +def worker(worker_id): + claimed = [] + conn = sqlite3.connect(db_path, timeout=10, isolation_level=None) + conn.execute("pragma busy_timeout=10000") + try: + while True: + try: + conn.execute("begin immediate") + row = conn.execute( + "select run_id from agent_run where status = 'queued' " + "order by priority desc, id asc limit 1" + ).fetchone() + if row is None: + conn.execute("commit") + return claimed + run_id = row[0] + updated = conn.execute( + "update agent_run " + "set status = 'completed', claimed_by_runtime_id = ?, dispatch_attempts = coalesce(dispatch_attempts, 0) + 1 " + "where run_id = ? and status = 'queued'", + (f"worker-{worker_id}", run_id), + ).rowcount + conn.execute("commit") + if updated == 1: + claimed.append(run_id) + except sqlite3.OperationalError as exc: + try: + conn.execute("rollback") + except sqlite3.OperationalError: + pass + if "locked" not in str(exc).lower(): + raise + time.sleep(0.01) + finally: + conn.close() + +with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as pool: + claims = [run_id for worker_claims in pool.map(worker, range(worker_count)) for run_id in worker_claims] + +conn = sqlite3.connect(db_path) +rows = conn.execute( + "select run_id, status, dispatch_attempts, claimed_by_runtime_id from agent_run" +).fetchall() +conn.close() + +assert len(claims) == run_count, len(claims) +assert len(set(claims)) == run_count, "duplicate claims detected" +assert all(row[1] == "completed" for row in rows), rows[:5] +assert all(row[2] == 1 for row in rows), rows[:5] +assert all(row[3] for row in rows), rows[:5] +print(f"LEDGER_CONTENTION_OK runs={run_count} workers={worker_count}") +`; + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "agent-runner-ledger-contention"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + const startedAt = new Date(); + const langbotRepo = resolve(root, env.LANGBOT_REPO || "../LangBot"); + const sdkSrc = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk/src"); + const dbPath = join(evidenceDir, "ledger-contention.sqlite3"); + const stdoutLog = join(evidenceDir, "probe-stdout.log"); + const stderrLog = join(evidenceDir, "probe-stderr.log"); + const automationResultJson = join(evidenceDir, "automation-result.json"); + const resultJson = join(evidenceDir, "result.json"); + const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); + const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script, dbPath], cwd: langbotRepo }; + const result = { + source: "automation", + probe: "agent-runner-ledger-contention", + case_id: caseId, + run_id: runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + status: "fail", + reason: "", + repo_path: langbotRepo, + python_paths: [sdkSrc], + database_path: dbPath, + command, + timeout_ms: timeoutMs, + exit_status: null, + signal: null, + evidence: { stdout_log: stdoutLog, stderr_log: stderrLog, database: dbPath, automation_result_json: automationResultJson, result_json: resultJson }, + evidence_collected: ["filesystem"], + }; + try { + if (!existsSync(langbotRepo)) { + result.status = "env_issue"; + result.reason = `LANGBOT_REPO/default ../LangBot did not resolve: ${langbotRepo}`; + } else { + const proc = await run(command, timeoutMs, { + ...process.env, + PYTHONPATH: [sdkSrc, process.env.PYTHONPATH].filter(Boolean).join(delimiter), + UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"), + }); + await writeFile(stdoutLog, proc.stdout, "utf8"); + await writeFile(stderrLog, proc.stderr, "utf8"); + result.exit_status = proc.status; + result.signal = proc.signal; + if (proc.error) { + result.status = "env_issue"; + result.reason = proc.error.message; + } else if (proc.timedOut) { + result.status = "fail"; + result.reason = `ledger contention timed out after ${timeoutMs}ms`; + } else if (proc.status === 0 && proc.stdout.includes("LEDGER_CONTENTION_OK")) { + result.status = "pass"; + result.reason = "ledger contention probe passed"; + } else { + result.status = "fail"; + result.reason = `ledger contention exited with status ${proc.status}`; + } + } + } catch (error) { + result.status = "fail"; + result.reason = error instanceof Error ? error.message : String(error); + } finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultJson, resultText, "utf8"); + await writeFile(resultJson, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } + process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs b/skills/skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs new file mode 100755 index 000000000..1b5416d10 --- /dev/null +++ b/skills/skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; +import { env } from "node:process"; + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + const pad = (value) => String(value).padStart(2, "0"); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function resolveFromRoot(root, value) { + return resolve(root, value); +} + +function runProcess(command, timeoutMs, childEnv) { + return new Promise((resolveDone) => { + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + detached: true, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + }, timeoutMs); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null }); + }); + child.on("close", (status, signal) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error: null, timedOut, status, signal }); + }); + }); +} + +const probeScript = String.raw` +import sqlite3 +from sqlalchemy import create_engine, inspect + +from langbot.pkg.agent.runner.run_ledger_store import TERMINAL_STATUSES +from langbot.pkg.entity.persistence.agent_run import AgentRun, AgentRunEvent, AgentRuntime + +expected_statuses = {'created', 'queued', 'claimed', 'running', 'completed', 'failed', 'cancelled', 'timeout'} +expected_terminal = {'completed', 'failed', 'cancelled', 'timeout'} +assert TERMINAL_STATUSES == expected_terminal, TERMINAL_STATUSES + +engine = create_engine('sqlite:///:memory:') +for table in (AgentRun.__table__, AgentRunEvent.__table__, AgentRuntime.__table__): + table.create(engine) + +inspector = inspect(engine) +assert set(inspector.get_table_names()) == {'agent_run', 'agent_run_event', 'agent_runtime'} +agent_run_indexes = {index['name']: tuple(index['column_names']) for index in inspector.get_indexes('agent_run')} +for name in ( + 'ix_agent_run_scope_status', + 'ix_agent_run_runner_status', + 'ix_agent_run_queue_claim', + 'ix_agent_run_run_id', + 'ix_agent_run_claim_token', +): + assert name in agent_run_indexes, agent_run_indexes + +event_uniques = { + unique['name']: tuple(unique['column_names']) + for unique in inspector.get_unique_constraints('agent_run_event') +} +assert event_uniques['uq_agent_run_event_run_sequence'] == ('run_id', 'sequence') + +with engine.begin() as conn: + conn.execute(AgentRun.__table__.insert().values( + run_id='run-sync', + event_id='evt-sync', + binding_id='binding-sync', + runner_id='plugin:test/runner/default', + status='queued', + queue_name='default', + priority=10, + )) + row = conn.execute(AgentRun.__table__.select().where(AgentRun.__table__.c.run_id == 'run-sync')).mappings().one() + assert row['status'] == 'queued' + conn.execute(AgentRunEvent.__table__.insert().values( + run_id='run-sync', + sequence=1, + type='message.completed', + data_json='{}', + source='runner', + )) + +print('LEDGER_INVARIANTS_OK tables=3 statuses=8') +`; + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "agent-runner-ledger-invariants"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + const startedAt = new Date(); + const langbotRepo = resolveFromRoot(root, env.LANGBOT_REPO || "../LangBot"); + const sdkSrc = resolveFromRoot(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk/src"); + const stdoutLog = join(evidenceDir, "probe-stdout.log"); + const stderrLog = join(evidenceDir, "probe-stderr.log"); + const automationResultJson = join(evidenceDir, "automation-result.json"); + const resultJson = join(evidenceDir, "result.json"); + const command = { executable: "rtk", args: ["uv", "run", "python", "-c", probeScript], cwd: langbotRepo }; + const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); + const result = { + source: "automation", + probe: "python-sync", + case_id: caseId, + run_id: runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + status: "fail", + reason: "", + repo_path: langbotRepo, + python_paths: [sdkSrc], + command, + timeout_ms: timeoutMs, + exit_status: null, + signal: null, + evidence: { + stdout_log: stdoutLog, + stderr_log: stderrLog, + automation_result_json: automationResultJson, + result_json: resultJson, + }, + evidence_collected: ["filesystem"], + }; + + try { + if (!existsSync(langbotRepo)) { + result.status = "env_issue"; + result.reason = `LANGBOT_REPO/default ../LangBot did not resolve: ${langbotRepo}`; + } else { + const childEnv = { + ...process.env, + PYTHONPATH: [sdkSrc, process.env.PYTHONPATH].filter(Boolean).join(delimiter), + UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"), + }; + await mkdir(childEnv.UV_CACHE_DIR, { recursive: true }); + const proc = await runProcess(command, timeoutMs, childEnv); + result.exit_status = proc.status; + result.signal = proc.signal; + await writeFile(stdoutLog, proc.stdout, "utf8"); + await writeFile(stderrLog, proc.stderr, "utf8"); + if (proc.error) { + result.status = "env_issue"; + result.reason = proc.error.message; + } else if (proc.timedOut) { + result.status = "fail"; + result.reason = `ledger invariant probe timed out after ${timeoutMs}ms`; + } else if (proc.status === 0) { + result.status = "pass"; + result.reason = "ledger invariant probe passed"; + } else { + result.status = "fail"; + result.reason = `ledger invariant probe exited with status ${proc.status}`; + } + } + } catch (error) { + result.status = "fail"; + result.reason = error instanceof Error ? error.message : String(error); + } finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultJson, resultText, "utf8"); + await writeFile(resultJson, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } + process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/agent-runner-ledger-stress.mjs b/skills/skills/langbot-testing/probes/agent-runner-ledger-stress.mjs new file mode 100755 index 000000000..3575358a2 --- /dev/null +++ b/skills/skills/langbot-testing/probes/agent-runner-ledger-stress.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; +import { env } from "node:process"; + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + const pad = (value) => String(value).padStart(2, "0"); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function run(command, timeoutMs, childEnv) { + return new Promise((resolveDone) => { + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + detached: true, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + }, timeoutMs); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null }); + }); + child.on("close", (status, signal) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error: null, timedOut, status, signal }); + }); + }); +} + +const script = String.raw` +from sqlalchemy import create_engine, select, update + +from langbot.pkg.entity.persistence.agent_run import AgentRun, AgentRunEvent, AgentRuntime + +run_count = 100 +runtime_count = 5 +engine = create_engine('sqlite:///:memory:') +for table in (AgentRun.__table__, AgentRunEvent.__table__, AgentRuntime.__table__): + table.create(engine) + +claimed = [] +with engine.begin() as conn: + conn.execute(AgentRun.__table__.insert(), [ + { + 'run_id': f'run-{i:03d}', + 'event_id': f'evt-{i:03d}', + 'binding_id': 'binding-stress', + 'runner_id': 'plugin:qa/agent-runner/default', + 'status': 'queued', + 'queue_name': 'default', + 'priority': run_count - i, + } + for i in range(run_count) + ]) + while True: + row = conn.execute( + select(AgentRun.__table__) + .where(AgentRun.__table__.c.status == 'queued') + .order_by(AgentRun.__table__.c.priority.desc(), AgentRun.__table__.c.id.asc()) + .limit(1) + ).mappings().first() + if row is None: + break + runtime_id = f'runtime-{len(claimed) % runtime_count}' + conn.execute( + update(AgentRun.__table__) + .where(AgentRun.__table__.c.run_id == row['run_id']) + .values(status='completed', claimed_by_runtime_id=runtime_id, dispatch_attempts=1) + ) + claimed.append(row['run_id']) + rows = conn.execute(select(AgentRun.__table__)).mappings().all() + +assert len(claimed) == run_count +assert len(set(claimed)) == run_count +assert all(row['status'] == 'completed' for row in rows) +assert all(row['dispatch_attempts'] == 1 for row in rows) +assert claimed[0] == 'run-000' +assert claimed[-1] == 'run-099' +print(f'LEDGER_STRESS_OK runs={run_count} runtimes={runtime_count}') +`; + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "agent-runner-ledger-stress"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + const startedAt = new Date(); + const langbotRepo = resolve(root, env.LANGBOT_REPO || "../LangBot"); + const sdkSrc = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk/src"); + const stdoutLog = join(evidenceDir, "probe-stdout.log"); + const stderrLog = join(evidenceDir, "probe-stderr.log"); + const automationResultJson = join(evidenceDir, "automation-result.json"); + const resultJson = join(evidenceDir, "result.json"); + const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); + const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script], cwd: langbotRepo }; + const result = { + source: "automation", + probe: "agent-runner-ledger-stress", + case_id: caseId, + run_id: runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + status: "fail", + reason: "", + repo_path: langbotRepo, + python_paths: [sdkSrc], + command, + timeout_ms: timeoutMs, + exit_status: null, + signal: null, + evidence: { stdout_log: stdoutLog, stderr_log: stderrLog, automation_result_json: automationResultJson, result_json: resultJson }, + evidence_collected: ["filesystem"], + }; + try { + if (!existsSync(langbotRepo)) { + result.status = "env_issue"; + result.reason = `LANGBOT_REPO/default ../LangBot did not resolve: ${langbotRepo}`; + } else { + const proc = await run(command, timeoutMs, { + ...process.env, + PYTHONPATH: [sdkSrc, process.env.PYTHONPATH].filter(Boolean).join(delimiter), + UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"), + }); + await writeFile(stdoutLog, proc.stdout, "utf8"); + await writeFile(stderrLog, proc.stderr, "utf8"); + result.exit_status = proc.status; + result.signal = proc.signal; + if (proc.error) { + result.status = "env_issue"; + result.reason = proc.error.message; + } else if (proc.timedOut) { + result.status = "fail"; + result.reason = `ledger stress timed out after ${timeoutMs}ms`; + } else if (proc.status === 0 && proc.stdout.includes("LEDGER_STRESS_OK")) { + result.status = "pass"; + result.reason = "ledger stress probe passed"; + } else { + result.status = "fail"; + result.reason = `ledger stress exited with status ${proc.status}`; + } + } + } catch (error) { + result.status = "fail"; + result.reason = error instanceof Error ? error.message : String(error); + } finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultJson, resultText, "utf8"); + await writeFile(resultJson, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } + process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs b/skills/skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs new file mode 100755 index 000000000..07ee5f988 --- /dev/null +++ b/skills/skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +import { runPytestProbe } from "./pytest-probe.mjs"; + +await runPytestProbe({ + caseId: "agent-runner-runtime-chaos", + repoEnvKey: "LANGBOT_PLUGIN_SDK_REPO", + defaultRepo: "../langbot-plugin-sdk", + description: "LangBot plugin SDK AgentRunner runtime failure, timeout, forwarding, and pull API pytest probe.", + testTargets: [ + "tests/runtime/plugin/test_mgr_agent_runner.py", + "tests/runtime/test_pull_api_handlers.py", + ], +}); diff --git a/skills/skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs b/skills/skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs new file mode 100755 index 000000000..af5153dbf --- /dev/null +++ b/skills/skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs @@ -0,0 +1,837 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; +import net from "node:net"; +import tls from "node:tls"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { env, exit } from "node:process"; +import { + apiJson, + appendLine, + ensureEvidence, + evidencePaths, + loadEnvFiles, + localIsoWithOffset, + redact, + resetAndAuthLocalUser, + writeResult, +} from "../../../scripts/e2e/lib/langbot-e2e.mjs"; +import { + buildProviderTimingMetrics, + summarizeFakeProviderState, +} from "./lib/fake-provider-timing.mjs"; + +const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; + +await loadEnvFiles(); +const caseId = env.LBS_CASE_ID || "langbot-debug-chat-concurrency"; +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const startedAt = new Date(); +const metricsPath = resolve(paths.evidenceDir, "metrics.json"); +const samplesPath = resolve(paths.evidenceDir, "samples.json"); +const fakeProviderStatePath = resolve(paths.evidenceDir, "fake-provider-state.json"); +const resetDiagnosticPath = resolve(paths.evidenceDir, "debug-chat-reset-diagnostic.json"); +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const fakeProviderUrl = env.LANGBOT_FAKE_PROVIDER_URL || ""; +const pipelineUrl = env.LANGBOT_E2E_PIPELINE_URL || env.LANGBOT_PIPELINE_URL || ""; +const pipelineName = env.LANGBOT_E2E_PIPELINE_NAME || env.LANGBOT_PIPELINE_NAME || ""; +const sessionType = env.LANGBOT_DEBUG_CHAT_LOAD_SESSION_TYPE || env.LANGBOT_E2E_DEBUG_CHAT_SESSION_TYPE || "person"; +const totalRequests = positiveInteger(env.LANGBOT_DEBUG_CHAT_LOAD_REQUESTS, defaultRequests(caseId)); +const concurrency = Math.min(totalRequests, positiveInteger(env.LANGBOT_DEBUG_CHAT_LOAD_CONCURRENCY, defaultConcurrency(caseId))); +const timeoutMs = positiveInteger(env.LANGBOT_DEBUG_CHAT_LOAD_TIMEOUT_MS, defaultTimeout(caseId)); +const expectedPrefix = env.LANGBOT_DEBUG_CHAT_LOAD_EXPECTED_PREFIX || "LBQA"; +const promptTemplate = env.LANGBOT_DEBUG_CHAT_LOAD_PROMPT_TEMPLATE + || "请只回复 \"{expected}\",不要解释,不要添加其他字符。"; +const stream = bool(env.LANGBOT_DEBUG_CHAT_LOAD_STREAM, true); +const resetBeforeRun = bool(env.LANGBOT_DEBUG_CHAT_LOAD_RESET, true); +const responseP95BudgetMs = positiveNumber(env.LANGBOT_DEBUG_CHAT_LOAD_RESPONSE_P95_MS, defaultP95Budget(caseId)); +const firstResponseP95BudgetMs = positiveNumber(env.LANGBOT_DEBUG_CHAT_LOAD_FIRST_RESPONSE_P95_MS, 0); +const maxErrorRate = positiveNumber(env.LANGBOT_DEBUG_CHAT_LOAD_MAX_ERROR_RATE, 0); +const minErrorRate = positiveNumber(env.LANGBOT_DEBUG_CHAT_LOAD_MIN_ERROR_RATE, 0); +const minErrorCount = nonNegativeInteger(env.LANGBOT_DEBUG_CHAT_LOAD_MIN_ERROR_COUNT, 0); +const minOkCount = nonNegativeInteger(env.LANGBOT_DEBUG_CHAT_LOAD_MIN_OK_COUNT, 0); +const minProviderFaultCount = nonNegativeInteger(env.LANGBOT_DEBUG_CHAT_LOAD_MIN_PROVIDER_FAULT_COUNT, 0); +const failOnFinalMismatch = bool(env.LANGBOT_DEBUG_CHAT_LOAD_FAIL_ON_FINAL_MISMATCH, false); +const failureSignals = textList(env.LANGBOT_E2E_FAILURE_SIGNALS || env.LANGBOT_DEBUG_CHAT_LOAD_FAILURE_SIGNALS || ""); + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + backend_url: backendUrl, + pipeline_url: pipelineUrl, + pipeline_name: pipelineName, + pipeline_id: "", + session_type: sessionType, + load_profile: { + requests: totalRequests, + concurrency, + timeout_ms: timeoutMs, + stream, + reset_before_run: resetBeforeRun, + fail_on_final_mismatch: failOnFinalMismatch, + }, + evidence: { + network_log: paths.networkLog, + metrics_json: metricsPath, + samples_json: samplesPath, + fake_provider_state_json: fakeProviderStatePath, + debug_chat_reset_diagnostic_json: resetDiagnosticPath, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["metrics", "network", "api_diagnostic", "filesystem"], +}; + +try { + if (!backendUrl) { + result.status = "env_issue"; + throw new Error("LANGBOT_BACKEND_URL is not configured."); + } + if (!["person", "group"].includes(sessionType)) { + throw new Error(`LANGBOT_DEBUG_CHAT_LOAD_SESSION_TYPE must be person or group, got ${sessionType}.`); + } + const backendReady = await backendReachable(backendUrl); + if (!backendReady) { + result.status = "env_issue"; + throw new Error(`Backend did not respond at ${backendUrl}.`); + } + + const user = env.LANGBOT_E2E_LOGIN_USER || ""; + const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD; + if (!user) { + result.status = "env_issue"; + throw new Error("LANGBOT_E2E_LOGIN_USER is required so this probe can resolve/reset the Debug Chat session."); + } + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + + const pipeline = await resolvePipeline({ backendUrl, token: auth.token, pipelineUrl, pipelineName }); + result.pipeline_id = pipeline.id; + result.pipeline_name = pipeline.name || pipelineName; + if (!result.pipeline_url && env.LANGBOT_FRONTEND_URL) { + result.pipeline_url = `${env.LANGBOT_FRONTEND_URL.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(pipeline.id)}`; + } + + if (resetBeforeRun) { + const reset = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.id)}/ws/reset/${encodeURIComponent(sessionType)}`, { + method: "POST", + token: auth.token, + }); + const resetDiagnostic = { + status: isApiFailure(reset) ? "fail" : "ready", + http_status: reset.status, + code: reset.json.code ?? null, + reason: isApiFailure(reset) ? reset.json.msg || "Debug Chat reset failed." : "Debug Chat session reset.", + }; + await writeFile(resetDiagnosticPath, `${JSON.stringify(resetDiagnostic, null, 2)}\n`, "utf8"); + if (resetDiagnostic.status === "fail") { + throw new Error(resetDiagnostic.reason); + } + } + + const wsUrl = websocketUrl(backendUrl, pipeline.id, sessionType); + const loadStartedAt = performance.now(); + const samples = await runLoad({ + wsUrl, + totalRequests, + concurrency, + timeoutMs, + promptTemplate, + expectedPrefix, + stream, + failOnFinalMismatch, + failureSignals, + }); + const loadDurationMs = performance.now() - loadStartedAt; + const fakeProviderState = await readFakeProviderState(fakeProviderUrl); + if (fakeProviderState) { + await writeFile(fakeProviderStatePath, `${JSON.stringify(fakeProviderState, null, 2)}\n`, "utf8"); + } + const metrics = buildMetrics({ + samples, + totalRequests, + concurrency, + timeoutMs, + loadDurationMs, + backendUrl, + pipelineId: pipeline.id, + sessionType, + fakeProviderState, + }); + const thresholds = buildThresholds(metrics); + const passed = Object.values(thresholds).every((item) => item.pass); + result.status = passed ? "pass" : "fail"; + result.reason = passed + ? "Debug Chat WebSocket concurrency probe passed all thresholds." + : "Debug Chat WebSocket concurrency probe breached latency or error-rate thresholds."; + result.metrics_summary = { + requests: metrics.total_requests, + concurrency: metrics.concurrency, + ok_count: metrics.ok_count, + error_count: metrics.error_count, + timeout_count: metrics.timeout_count, + error_rate: metrics.error_rate, + response_p50_ms: metrics.response_duration_ms.p50, + response_p95_ms: metrics.response_duration_ms.p95, + first_assistant_event_p95_ms: metrics.first_assistant_event_ms.p95, + first_assistant_content_p95_ms: metrics.first_assistant_content_ms.p95, + first_response_p95_ms: metrics.first_response_ms.p95, + throughput_rps: metrics.throughput_rps, + status_counts: metrics.status_counts, + fake_provider_request_count: metrics.fake_provider?.request_count ?? null, + fake_provider_fault_count: metrics.fake_provider?.fault_count ?? null, + fake_provider_duration_p95_ms: metrics.provider_timing?.provider_duration_ms.p95 ?? null, + langbot_overhead_estimate_p95_ms: metrics.provider_timing?.langbot_overhead_estimate_ms.p95 ?? null, + send_to_provider_start_p95_ms: metrics.provider_timing?.send_to_provider_start_ms.p95 ?? null, + provider_finish_to_ws_final_p95_ms: metrics.provider_timing?.provider_finish_to_ws_final_ms.p95 ?? null, + provider_timing_matched_request_count: metrics.provider_timing?.matched_request_count ?? null, + }; + result.thresholds_summary = thresholds; + result.artifacts = { + metrics_json: metricsPath, + samples_json: samplesPath, + fake_provider_state_json: fakeProviderState ? fakeProviderStatePath : "", + network_log: paths.networkLog, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }; + + await writeFile(metricsPath, `${JSON.stringify({ ...metrics, thresholds }, null, 2)}\n`, "utf8"); + await writeFile(samplesPath, `${JSON.stringify(samples, null, 2)}\n`, "utf8"); +} catch (error) { + if (!["env_issue", "blocked"].includes(result.status)) { + result.status = looksLikeEnvIssue(error) ? "env_issue" : "fail"; + } + result.reason = result.reason || safeReason(error.message); +} finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + await mkdir(paths.evidenceDir, { recursive: true }); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +exit(result.status === "pass" ? 0 : result.status === "env_issue" || result.status === "blocked" ? 2 : 1); + +function defaultRequests(id) { + return id.includes("space") ? 3 : 12; +} + +function defaultConcurrency(id) { + return id.includes("space") ? 1 : 4; +} + +function defaultTimeout(id) { + return id.includes("space") ? 120_000 : 30_000; +} + +function defaultP95Budget(id) { + return id.includes("space") ? 120_000 : 5_000; +} + +function positiveInteger(value, fallback) { + const parsed = Number.parseInt(String(value || ""), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function nonNegativeInteger(value, fallback) { + const parsed = Number.parseInt(String(value ?? ""), 10); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; +} + +function positiveNumber(value, fallback) { + const parsed = Number(value || ""); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +function bool(value, fallback) { + if (value === undefined || value === "") return fallback; + if (/^(1|true|yes|on)$/i.test(String(value))) return true; + if (/^(0|false|no|off)$/i.test(String(value))) return false; + return fallback; +} + +function textList(value) { + return String(value || "") + .split(/\r?\n|,/) + .map((item) => item.trim()) + .filter(Boolean); +} + +async function backendReachable(baseUrl) { + try { + const response = await fetch(`${baseUrl.replace(/\/$/, "")}/healthz`, { + signal: AbortSignal.timeout(3000), + }); + return response.status < 500; + } catch { + return false; + } +} + +async function readFakeProviderState(rootUrl) { + if (!rootUrl) return null; + try { + const response = await fetch(`${normalizeProviderRootUrl(rootUrl)}/__qa/config`, { + signal: AbortSignal.timeout(3000), + }); + const json = await response.json().catch(() => ({})); + return { + status: response.ok && json.ok === true ? "loaded" : "unavailable", + url: normalizeProviderRootUrl(rootUrl), + http_status: response.status, + model: json.model || "", + config: json.config || {}, + request_count: Number.isFinite(json.request_count) ? json.request_count : null, + recent_requests: Array.isArray(json.recent_requests) ? json.recent_requests : [], + }; + } catch (error) { + return { + status: "unavailable", + url: normalizeProviderRootUrl(rootUrl), + reason: safeReason(error.message), + request_count: null, + recent_requests: [], + }; + } +} + +function normalizeProviderRootUrl(value) { + const trimmed = String(value || "").trim().replace(/\/$/, ""); + return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed; +} + +function pipelineIdFromUrl(url) { + if (!url) return ""; + try { + const parsed = new URL(url); + return parsed.searchParams.get("id") || ""; + } catch { + return ""; + } +} + +async function resolvePipeline({ backendUrl, token, pipelineUrl, pipelineName }) { + const idFromUrl = pipelineIdFromUrl(pipelineUrl); + if (idFromUrl) { + const response = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(idFromUrl)}`, { token }); + const pipeline = response.json.data?.pipeline; + if (isApiFailure(response) || !pipeline?.uuid) { + throw new Error(response.json.msg || `Could not load pipeline ${idFromUrl}.`); + } + return { id: pipeline.uuid, name: pipeline.name || "" }; + } + if (!pipelineName) { + throw new Error("Set LANGBOT_E2E_PIPELINE_URL or LANGBOT_E2E_PIPELINE_NAME before running this probe."); + } + const response = await apiJson(backendUrl, "/api/v1/pipelines", { token }); + if (isApiFailure(response)) { + throw new Error(response.json.msg || "Failed to list pipelines."); + } + const pipeline = (response.json.data?.pipelines || []).find((item) => item.name === pipelineName); + if (!pipeline?.uuid) { + throw new Error(`Could not find pipeline named ${pipelineName}.`); + } + return { id: pipeline.uuid, name: pipeline.name || pipelineName }; +} + +function isApiFailure(response) { + return response.status >= 400 || (response.json.code !== undefined && response.json.code !== 0); +} + +function websocketUrl(baseUrl, pipelineId, sessionType) { + const parsed = new URL(baseUrl); + parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:"; + parsed.pathname = `/api/v1/pipelines/${encodeURIComponent(pipelineId)}/ws/connect`; + parsed.search = `?session_type=${encodeURIComponent(sessionType)}`; + return parsed.toString(); +} + +async function runLoad(options) { + const samples = []; + let nextIndex = 0; + const workers = Array.from({ length: options.concurrency }, async () => { + while (nextIndex < options.totalRequests) { + const index = nextIndex; + nextIndex += 1; + const sample = await runSingleRequest({ ...options, index }); + samples.push(sample); + } + }); + await Promise.all(workers); + return samples.sort((left, right) => left.index - right.index); +} + +function expectedForIndex(prefix, index) { + return `${prefix}-${String(index + 1).padStart(4, "0")}`; +} + +function promptForIndex(template, expected) { + return template.replaceAll("{expected}", expected); +} + +function runSingleRequest({ + wsUrl, + index, + timeoutMs, + promptTemplate, + expectedPrefix, + stream, + failOnFinalMismatch, + failureSignals, +}) { + return new Promise((resolve) => { + const expected = expectedForIndex(expectedPrefix, index); + const prompt = promptForIndex(promptTemplate, expected); + const sample = { + index, + status: "running", + ok: false, + expected_text: expected, + prompt, + response_text: "", + started_at: new Date().toISOString(), + started_epoch_ms: Date.now(), + connected_at: null, + connected_epoch_ms: null, + sent_at: null, + sent_epoch_ms: null, + first_assistant_event_at: null, + first_assistant_event_epoch_ms: null, + first_assistant_event_ms: null, + first_assistant_content_at: null, + first_assistant_content_epoch_ms: null, + first_assistant_content_ms: null, + first_response_at: null, + first_response_epoch_ms: null, + connected_ms: null, + first_response_ms: null, + response_duration_ms: null, + finished_at: null, + finished_epoch_ms: null, + event_count: 0, + foreign_response_count: 0, + last_foreign_response_text: "", + error: "", + close_code: null, + close_reason: "", + }; + let closed = false; + let connectedAt = 0; + let sentAt = 0; + const startedAt = performance.now(); + let client = null; + const timer = setTimeout(() => { + finish("timeout", `Timed out after ${timeoutMs} ms.`); + }, timeoutMs); + + client = openRawWebSocket(wsUrl, { + onOpen() { + connectedAt = performance.now(); + const now = Date.now(); + sample.connected_at = new Date(now).toISOString(); + sample.connected_epoch_ms = now; + sample.connected_ms = rounded(connectedAt - startedAt); + }, + onMessage(text) { + sample.event_count += 1; + let data; + try { + data = JSON.parse(String(text || "")); + } catch (error) { + finish("error", `Invalid WebSocket JSON: ${error.message}`); + return; + } + appendLine(paths.networkLog, JSON.stringify({ + request_index: index, + type: data.type, + session_type: data.session_type || "", + role: data.data?.role || "", + is_final: data.data?.is_final ?? null, + content_preview: redact(String(data.data?.content || data.message || "").slice(0, 200)), + })).catch(() => {}); + + if (data.type === "connected") { + sentAt = performance.now(); + const now = Date.now(); + sample.sent_at = new Date(now).toISOString(); + sample.sent_epoch_ms = now; + client.send(JSON.stringify({ + type: "message", + message: [{ type: "Plain", text: prompt }], + stream, + })); + return; + } + if (data.type === "error") { + finish("error", data.message || "WebSocket error message."); + return; + } + if (data.type !== "response" || data.data?.role !== "assistant") return; + + const content = String(data.data.content || ""); + markFirstAssistantEvent(sample, sentAt); + if (content) sample.response_text = content; + if (content) markFirstAssistantContent(sample, sentAt); + if (content.includes(expected) && sample.first_response_ms === null && sentAt > 0) { + const now = Date.now(); + sample.first_response_at = new Date(now).toISOString(); + sample.first_response_epoch_ms = now; + sample.first_response_ms = rounded(performance.now() - sentAt); + } + if (data.data.is_final === true) { + const ok = sample.response_text.includes(expected); + if (ok) { + if (sample.first_response_ms === null && sentAt > 0) { + sample.first_response_ms = rounded(performance.now() - sentAt); + } + finish("pass", ""); + } else if (matchesFailureSignal(sample.response_text, failureSignals)) { + finish("app_error", `Assistant final response matched a failure signal: ${sample.response_text}`); + } else if (failOnFinalMismatch && !containsLoadToken(sample.response_text, expectedPrefix)) { + finish("mismatch", `Final assistant response did not include ${expected}: ${sample.response_text}`); + } else { + sample.foreign_response_count += 1; + sample.last_foreign_response_text = sample.response_text; + } + } + }, + onError(error) { + finish("connection_error", `WebSocket connection error: ${error.message}`); + }, + onClose(event) { + sample.close_code = event.code; + sample.close_reason = event.reason || ""; + if (!closed) finish("closed", `WebSocket closed before final assistant response: ${event.code}`); + }, + }); + + function finish(status, reason) { + if (closed) return; + closed = true; + clearTimeout(timer); + sample.status = status; + sample.ok = status === "pass"; + sample.error = status === "timeout" && sample.foreign_response_count > 0 + ? `${reason || ""} Saw ${sample.foreign_response_count} foreign assistant response(s); last=${sample.last_foreign_response_text}` + : reason || ""; + if (sentAt > 0) sample.response_duration_ms = rounded(performance.now() - sentAt); + else sample.response_duration_ms = rounded(performance.now() - startedAt); + const now = Date.now(); + sample.finished_at = new Date(now).toISOString(); + sample.finished_epoch_ms = now; + try { + client?.close(); + } catch { + // Closing a failed socket should not hide the sample result. + } + resolve(sample); + } + }); +} + +function markFirstAssistantEvent(sample, sentAt) { + if (sample.first_assistant_event_ms !== null || sentAt <= 0) return; + const now = Date.now(); + sample.first_assistant_event_at = new Date(now).toISOString(); + sample.first_assistant_event_epoch_ms = now; + sample.first_assistant_event_ms = rounded(performance.now() - sentAt); +} + +function markFirstAssistantContent(sample, sentAt) { + if (sample.first_assistant_content_ms !== null || sentAt <= 0) return; + const now = Date.now(); + sample.first_assistant_content_at = new Date(now).toISOString(); + sample.first_assistant_content_epoch_ms = now; + sample.first_assistant_content_ms = rounded(performance.now() - sentAt); +} + +function containsLoadToken(text, prefix) { + const escaped = String(prefix).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`${escaped}-\\d{4}`).test(String(text || "")); +} + +function matchesFailureSignal(text, signals) { + const lower = String(text || "").toLowerCase(); + return signals.some((signal) => lower.includes(signal.toLowerCase())); +} + +function openRawWebSocket(wsUrl, handlers) { + const parsed = new URL(wsUrl); + const secure = parsed.protocol === "wss:"; + const port = Number(parsed.port || (secure ? 443 : 80)); + const host = parsed.hostname; + const path = `${parsed.pathname}${parsed.search}`; + const key = crypto.randomBytes(16).toString("base64"); + const socket = secure + ? tls.connect({ host, port, servername: host }) + : net.connect({ host, port }); + let opened = false; + let closed = false; + let buffer = Buffer.alloc(0); + + socket.setNoDelay(true); + socket.on("connect", () => { + const originProtocol = secure ? "https" : "http"; + const request = [ + `GET ${path} HTTP/1.1`, + `Host: ${parsed.host}`, + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Key: ${key}`, + "Sec-WebSocket-Version: 13", + `Origin: ${originProtocol}://${parsed.host}`, + "", + "", + ].join("\r\n"); + socket.write(request); + }); + socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + if (!opened) { + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const headerText = buffer.slice(0, headerEnd).toString("utf8"); + buffer = buffer.slice(headerEnd + 4); + if (!/^HTTP\/1\.1 101\b/i.test(headerText)) { + handlers.onError(new Error(`Handshake failed: ${headerText.split("\r\n")[0] || "missing status"}`)); + socket.destroy(); + return; + } + opened = true; + handlers.onOpen(); + } + processFrames(); + }); + socket.on("error", (error) => { + if (!closed) handlers.onError(error); + }); + socket.on("close", () => { + if (closed) return; + closed = true; + handlers.onClose({ code: null, reason: "" }); + }); + + function processFrames() { + while (true) { + const frame = readFrame(buffer); + if (!frame) return; + buffer = buffer.slice(frame.consumed); + if (frame.opcode === 0x1) { + handlers.onMessage(frame.payload.toString("utf8")); + } else if (frame.opcode === 0x8) { + const code = frame.payload.length >= 2 ? frame.payload.readUInt16BE(0) : null; + const reason = frame.payload.length > 2 ? frame.payload.slice(2).toString("utf8") : ""; + closed = true; + handlers.onClose({ code, reason }); + socket.end(); + return; + } else if (frame.opcode === 0x9) { + writeFrame(socket, 0xA, frame.payload); + } + } + } + + return { + send(text) { + if (closed || !opened) return; + writeFrame(socket, 0x1, Buffer.from(text, "utf8")); + }, + close() { + if (closed) return; + closed = true; + if (!socket.destroyed) { + if (opened) writeFrame(socket, 0x8, Buffer.alloc(0)); + setTimeout(() => socket.end(), 50).unref(); + } + }, + }; +} + +function readFrame(buffer) { + if (buffer.length < 2) return null; + const first = buffer[0]; + const second = buffer[1]; + const opcode = first & 0x0f; + const masked = Boolean(second & 0x80); + let length = second & 0x7f; + let offset = 2; + if (length === 126) { + if (buffer.length < offset + 2) return null; + length = buffer.readUInt16BE(offset); + offset += 2; + } else if (length === 127) { + if (buffer.length < offset + 8) return null; + const high = buffer.readUInt32BE(offset); + const low = buffer.readUInt32BE(offset + 4); + length = high * 2 ** 32 + low; + offset += 8; + } + let mask = null; + if (masked) { + if (buffer.length < offset + 4) return null; + mask = buffer.slice(offset, offset + 4); + offset += 4; + } + if (buffer.length < offset + length) return null; + let payload = buffer.slice(offset, offset + length); + if (mask) { + payload = Buffer.from(payload); + for (let index = 0; index < payload.length; index += 1) { + payload[index] ^= mask[index % 4]; + } + } + return { + opcode, + payload, + consumed: offset + length, + }; +} + +function writeFrame(socket, opcode, payload) { + const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload || ""); + const mask = crypto.randomBytes(4); + const headerLength = body.length < 126 ? 2 : body.length <= 0xffff ? 4 : 10; + const header = Buffer.alloc(headerLength); + header[0] = 0x80 | opcode; + if (body.length < 126) { + header[1] = 0x80 | body.length; + } else if (body.length <= 0xffff) { + header[1] = 0x80 | 126; + header.writeUInt16BE(body.length, 2); + } else { + header[1] = 0x80 | 127; + header.writeUInt32BE(Math.floor(body.length / 2 ** 32), 2); + header.writeUInt32BE(body.length >>> 0, 6); + } + const masked = Buffer.from(body); + for (let index = 0; index < masked.length; index += 1) { + masked[index] ^= mask[index % 4]; + } + socket.write(Buffer.concat([header, mask, masked])); +} + +function rounded(value) { + return Number(value.toFixed(3)); +} + +function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1); + return rounded(sorted[index]); +} + +function stats(values) { + if (values.length === 0) return { min: 0, p50: 0, p95: 0, p99: 0, max: 0 }; + return { + min: rounded(Math.min(...values)), + p50: percentile(values, 50), + p95: percentile(values, 95), + p99: percentile(values, 99), + max: rounded(Math.max(...values)), + }; +} + +function buildMetrics({ samples, totalRequests, concurrency, timeoutMs, loadDurationMs, backendUrl, pipelineId, sessionType, fakeProviderState }) { + const okSamples = samples.filter((sample) => sample.ok); + const statusCounts = {}; + for (const sample of samples) { + statusCounts[sample.status] = (statusCounts[sample.status] || 0) + 1; + } + const errorCount = samples.length - okSamples.length; + return { + probe: caseId, + backend_url: backendUrl, + pipeline_id: pipelineId, + session_type: sessionType, + total_requests: totalRequests, + completed_requests: samples.length, + concurrency, + timeout_ms: timeoutMs, + ok_count: okSamples.length, + error_count: errorCount, + timeout_count: samples.filter((sample) => sample.status === "timeout").length, + error_rate: samples.length === 0 ? 1 : rounded(errorCount / samples.length), + load_duration_ms: rounded(loadDurationMs), + throughput_rps: loadDurationMs <= 0 ? 0 : rounded(okSamples.length / (loadDurationMs / 1000)), + status_counts: statusCounts, + connected_ms: stats(samples.map((sample) => sample.connected_ms).filter(Number.isFinite)), + first_assistant_event_ms: stats(samples.map((sample) => sample.first_assistant_event_ms).filter(Number.isFinite)), + first_assistant_content_ms: stats(samples.map((sample) => sample.first_assistant_content_ms).filter(Number.isFinite)), + first_response_ms: stats(okSamples.map((sample) => sample.first_response_ms).filter(Number.isFinite)), + response_duration_ms: stats(okSamples.map((sample) => sample.response_duration_ms).filter(Number.isFinite)), + fake_provider: summarizeFakeProviderState(fakeProviderState), + provider_timing: buildProviderTimingMetrics(samples, fakeProviderState), + samples, + }; +} + +function buildThresholds(metrics) { + const thresholds = { + error_rate: { actual: metrics.error_rate, max: maxErrorRate, pass: metrics.error_rate <= maxErrorRate }, + response_p95_ms: { + actual: metrics.response_duration_ms.p95, + max: responseP95BudgetMs, + pass: metrics.ok_count > 0 && metrics.response_duration_ms.p95 <= responseP95BudgetMs, + }, + }; + if (minErrorRate > 0) { + thresholds.error_rate_min = { + actual: metrics.error_rate, + min: minErrorRate, + pass: metrics.error_rate >= minErrorRate, + }; + } + if (minErrorCount > 0) { + thresholds.error_count_min = { + actual: metrics.error_count, + min: minErrorCount, + pass: metrics.error_count >= minErrorCount, + }; + } + if (minOkCount > 0) { + thresholds.ok_count_min = { + actual: metrics.ok_count, + min: minOkCount, + pass: metrics.ok_count >= minOkCount, + }; + } + if (minProviderFaultCount > 0) { + const actual = metrics.fake_provider?.fault_count ?? 0; + thresholds.fake_provider_fault_count_min = { + actual, + min: minProviderFaultCount, + pass: actual >= minProviderFaultCount, + }; + } + if (firstResponseP95BudgetMs > 0) { + thresholds.first_response_p95_ms = { + actual: metrics.first_response_ms.p95, + max: firstResponseP95BudgetMs, + pass: metrics.ok_count > 0 && metrics.first_response_ms.p95 <= firstResponseP95BudgetMs, + }; + } + return thresholds; +} + +function looksLikeEnvIssue(error) { + const message = String(error?.message || error || ""); + return /fetch failed|ECONNREFUSED|ENOTFOUND|LANGBOT_.*not configured|Could not read recovery_key|Backend did not respond/i.test(message); +} + +function safeReason(value) { + return redact(String(value || "")).slice(0, 1000); +} diff --git a/skills/skills/langbot-testing/probes/langbot-debug-chat-cross-pipeline-isolation.mjs b/skills/skills/langbot-testing/probes/langbot-debug-chat-cross-pipeline-isolation.mjs new file mode 100755 index 000000000..b83f6161d --- /dev/null +++ b/skills/skills/langbot-testing/probes/langbot-debug-chat-cross-pipeline-isolation.mjs @@ -0,0 +1,861 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; +import net from "node:net"; +import tls from "node:tls"; +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env, exit } from "node:process"; +import { + apiJson, + appendLine, + ensureEvidence, + evidencePaths, + loadEnvFiles, + localIsoWithOffset, + redact, + resetAndAuthLocalUser, + writeResult, +} from "../../../scripts/e2e/lib/langbot-e2e.mjs"; +import { + buildProviderTimingMetrics, + summarizeFakeProviderState, +} from "./lib/fake-provider-timing.mjs"; + +const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; + +await loadEnvFiles(); +const caseId = env.LBS_CASE_ID || "langbot-debug-chat-cross-pipeline-isolation"; +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const startedAt = new Date(); +const metricsPath = resolve(paths.evidenceDir, "metrics.json"); +const samplesPath = resolve(paths.evidenceDir, "samples.json"); +const fakeProviderStatePath = resolve(paths.evidenceDir, "fake-provider-state.json"); +const resetDiagnosticPath = resolve(paths.evidenceDir, "debug-chat-reset-diagnostic.json"); +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const fakeProviderUrl = env.LANGBOT_FAKE_PROVIDER_URL || ""; +const sessionType = env.LANGBOT_DEBUG_CHAT_LOAD_SESSION_TYPE || env.LANGBOT_E2E_DEBUG_CHAT_SESSION_TYPE || "person"; +const requestsPerPipeline = positiveInteger(env.LANGBOT_DEBUG_CHAT_LOAD_REQUESTS, 6); +const concurrency = Math.min(requestsPerPipeline * 2, positiveInteger(env.LANGBOT_DEBUG_CHAT_LOAD_CONCURRENCY, 4)); +const timeoutMs = positiveInteger(env.LANGBOT_DEBUG_CHAT_LOAD_TIMEOUT_MS, 30_000); +const stream = bool(env.LANGBOT_DEBUG_CHAT_LOAD_STREAM, true); +const resetBeforeRun = bool(env.LANGBOT_DEBUG_CHAT_LOAD_RESET, true); +const responseP95BudgetMs = positiveNumber(env.LANGBOT_DEBUG_CHAT_LOAD_RESPONSE_P95_MS, 5_000); +const maxErrorRate = positiveNumber(env.LANGBOT_DEBUG_CHAT_LOAD_MAX_ERROR_RATE, 0); +const promptTemplate = env.LANGBOT_DEBUG_CHAT_LOAD_PROMPT_TEMPLATE + || "请只回复 \"{expected}\",不要解释,不要添加其他字符。"; +const failureSignals = textList(env.LANGBOT_E2E_FAILURE_SIGNALS || env.LANGBOT_DEBUG_CHAT_LOAD_FAILURE_SIGNALS || ""); + +const pipelineTargets = [ + { + label: "A", + expectedPrefix: "PIPEA", + otherPrefix: "PIPEB", + url: env.LANGBOT_FAKE_PROVIDER_PIPELINE_A_URL || "", + name: env.LANGBOT_FAKE_PROVIDER_PIPELINE_A_NAME || "", + }, + { + label: "B", + expectedPrefix: "PIPEB", + otherPrefix: "PIPEA", + url: env.LANGBOT_FAKE_PROVIDER_PIPELINE_B_URL || "", + name: env.LANGBOT_FAKE_PROVIDER_PIPELINE_B_NAME || "", + }, +]; + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + backend_url: backendUrl, + session_type: sessionType, + pipelines: [], + load_profile: { + requests_per_pipeline: requestsPerPipeline, + total_requests: requestsPerPipeline * 2, + concurrency, + timeout_ms: timeoutMs, + stream, + reset_before_run: resetBeforeRun, + }, + evidence: { + network_log: paths.networkLog, + metrics_json: metricsPath, + samples_json: samplesPath, + fake_provider_state_json: fakeProviderStatePath, + debug_chat_reset_diagnostic_json: resetDiagnosticPath, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["metrics", "network", "api_diagnostic", "filesystem"], +}; + +try { + if (!backendUrl) { + result.status = "env_issue"; + throw new Error("LANGBOT_BACKEND_URL is not configured."); + } + if (!["person", "group"].includes(sessionType)) { + throw new Error(`LANGBOT_DEBUG_CHAT_LOAD_SESSION_TYPE must be person or group, got ${sessionType}.`); + } + for (const target of pipelineTargets) { + if (!target.url && !target.name) { + result.status = "env_issue"; + throw new Error(`Set LANGBOT_FAKE_PROVIDER_PIPELINE_${target.label}_URL or LANGBOT_FAKE_PROVIDER_PIPELINE_${target.label}_NAME.`); + } + } + + const backendReady = await backendReachable(backendUrl); + if (!backendReady) { + result.status = "env_issue"; + throw new Error(`Backend did not respond at ${backendUrl}.`); + } + + const user = env.LANGBOT_E2E_LOGIN_USER || ""; + const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD; + if (!user) { + result.status = "env_issue"; + throw new Error("LANGBOT_E2E_LOGIN_USER is required so this probe can resolve/reset Debug Chat sessions."); + } + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + const pipelines = []; + for (const target of pipelineTargets) { + const pipeline = await resolvePipeline({ + backendUrl, + token: auth.token, + pipelineUrl: target.url, + pipelineName: target.name, + }); + pipelines.push({ + ...target, + id: pipeline.id, + name: pipeline.name || target.name, + wsUrl: websocketUrl(backendUrl, pipeline.id, sessionType), + }); + } + result.pipelines = pipelines.map((pipeline) => ({ + label: pipeline.label, + id: pipeline.id, + name: pipeline.name, + url: pipeline.url, + })); + + if (resetBeforeRun) { + const resetDiagnostics = []; + for (const pipeline of pipelines) { + const reset = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.id)}/ws/reset/${encodeURIComponent(sessionType)}`, { + method: "POST", + token: auth.token, + }); + resetDiagnostics.push({ + pipeline_label: pipeline.label, + pipeline_id: pipeline.id, + status: isApiFailure(reset) ? "fail" : "ready", + http_status: reset.status, + code: reset.json.code ?? null, + reason: isApiFailure(reset) ? reset.json.msg || "Debug Chat reset failed." : "Debug Chat session reset.", + }); + } + await writeFile(resetDiagnosticPath, `${JSON.stringify(resetDiagnostics, null, 2)}\n`, "utf8"); + const failedReset = resetDiagnostics.find((item) => item.status === "fail"); + if (failedReset) throw new Error(failedReset.reason); + } + await resetFakeProvider(fakeProviderUrl); + + const jobs = []; + for (let index = 0; index < requestsPerPipeline; index += 1) { + for (const pipeline of pipelines) { + jobs.push({ ...pipeline, index }); + } + } + + const loadStartedAt = performance.now(); + const samples = await runLoad({ + jobs, + concurrency, + timeoutMs, + promptTemplate, + stream, + failureSignals, + }); + const loadDurationMs = performance.now() - loadStartedAt; + const fakeProviderState = await readFakeProviderState(fakeProviderUrl); + if (fakeProviderState) { + await writeFile(fakeProviderStatePath, `${JSON.stringify(fakeProviderState, null, 2)}\n`, "utf8"); + } + const metrics = buildMetrics({ + samples, + requestsPerPipeline, + concurrency, + timeoutMs, + loadDurationMs, + backendUrl, + sessionType, + fakeProviderState, + }); + const thresholds = buildThresholds(metrics); + const passed = Object.values(thresholds).every((item) => item.pass); + result.status = passed ? "pass" : "fail"; + result.reason = passed + ? "Debug Chat cross-pipeline isolation probe passed all thresholds." + : "Debug Chat cross-pipeline isolation probe found leaks, errors, or latency threshold breaches."; + result.metrics_summary = { + requests_per_pipeline: metrics.requests_per_pipeline, + total_requests: metrics.total_requests, + concurrency: metrics.concurrency, + ok_count: metrics.ok_count, + error_count: metrics.error_count, + cross_pipeline_leak_count: metrics.cross_pipeline_leak_count, + timeout_count: metrics.timeout_count, + error_rate: metrics.error_rate, + response_p95_ms: metrics.response_duration_ms.p95, + first_response_p95_ms: metrics.first_response_ms.p95, + throughput_rps: metrics.throughput_rps, + status_counts: metrics.status_counts, + by_pipeline: metrics.by_pipeline, + fake_provider_request_count: metrics.fake_provider?.request_count ?? null, + fake_provider_duration_p95_ms: metrics.provider_timing?.provider_duration_ms.p95 ?? null, + langbot_overhead_estimate_p95_ms: metrics.provider_timing?.langbot_overhead_estimate_ms.p95 ?? null, + send_to_provider_start_p95_ms: metrics.provider_timing?.send_to_provider_start_ms.p95 ?? null, + provider_finish_to_ws_final_p95_ms: metrics.provider_timing?.provider_finish_to_ws_final_ms.p95 ?? null, + }; + result.thresholds_summary = thresholds; + result.artifacts = { + metrics_json: metricsPath, + samples_json: samplesPath, + fake_provider_state_json: fakeProviderState ? fakeProviderStatePath : "", + network_log: paths.networkLog, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }; + + await writeFile(metricsPath, `${JSON.stringify({ ...metrics, thresholds }, null, 2)}\n`, "utf8"); + await writeFile(samplesPath, `${JSON.stringify(samples, null, 2)}\n`, "utf8"); +} catch (error) { + if (!["env_issue", "blocked"].includes(result.status)) { + result.status = looksLikeEnvIssue(error) ? "env_issue" : "fail"; + } + result.reason = result.reason || safeReason(error.message); +} finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + await mkdir(paths.evidenceDir, { recursive: true }); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +exit(result.status === "pass" ? 0 : result.status === "env_issue" || result.status === "blocked" ? 2 : 1); + +async function backendReachable(baseUrl) { + try { + const response = await fetch(`${baseUrl.replace(/\/$/, "")}/healthz`, { + signal: AbortSignal.timeout(3000), + }); + return response.status < 500; + } catch { + return false; + } +} + +async function resetFakeProvider(rootUrl) { + if (!rootUrl) return; + try { + await fetch(`${normalizeProviderRootUrl(rootUrl)}/__qa/reset`, { + method: "POST", + signal: AbortSignal.timeout(3000), + }); + } catch { + // Missing fake-provider diagnostics should not hide the isolation result. + } +} + +async function readFakeProviderState(rootUrl) { + if (!rootUrl) return null; + try { + const response = await fetch(`${normalizeProviderRootUrl(rootUrl)}/__qa/config`, { + signal: AbortSignal.timeout(3000), + }); + const json = await response.json().catch(() => ({})); + return { + status: response.ok && json.ok === true ? "loaded" : "unavailable", + url: normalizeProviderRootUrl(rootUrl), + http_status: response.status, + model: json.model || "", + config: json.config || {}, + request_count: Number.isFinite(json.request_count) ? json.request_count : null, + recent_requests: Array.isArray(json.recent_requests) ? json.recent_requests : [], + }; + } catch (error) { + return { + status: "unavailable", + url: normalizeProviderRootUrl(rootUrl), + reason: safeReason(error.message), + request_count: null, + recent_requests: [], + }; + } +} + +function normalizeProviderRootUrl(value) { + const trimmed = String(value || "").trim().replace(/\/$/, ""); + return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed; +} + +function pipelineIdFromUrl(url) { + if (!url) return ""; + try { + const parsed = new URL(url); + return parsed.searchParams.get("id") || ""; + } catch { + return ""; + } +} + +async function resolvePipeline({ backendUrl, token, pipelineUrl, pipelineName }) { + const idFromUrl = pipelineIdFromUrl(pipelineUrl); + if (idFromUrl) { + const response = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(idFromUrl)}`, { token }); + const pipeline = response.json.data?.pipeline; + if (isApiFailure(response) || !pipeline?.uuid) { + throw new Error(response.json.msg || `Could not load pipeline ${idFromUrl}.`); + } + return { id: pipeline.uuid, name: pipeline.name || "" }; + } + if (!pipelineName) { + throw new Error("Set pipeline URL or name before running this probe."); + } + const response = await apiJson(backendUrl, "/api/v1/pipelines", { token }); + if (isApiFailure(response)) { + throw new Error(response.json.msg || "Failed to list pipelines."); + } + const pipeline = (response.json.data?.pipelines || []).find((item) => item.name === pipelineName); + if (!pipeline?.uuid) { + throw new Error(`Could not find pipeline named ${pipelineName}.`); + } + return { id: pipeline.uuid, name: pipeline.name || pipelineName }; +} + +function isApiFailure(response) { + return response.status >= 400 || (response.json.code !== undefined && response.json.code !== 0); +} + +function websocketUrl(baseUrl, pipelineId, sessionTypeValue) { + const parsed = new URL(baseUrl); + parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:"; + parsed.pathname = `/api/v1/pipelines/${encodeURIComponent(pipelineId)}/ws/connect`; + parsed.search = `?session_type=${encodeURIComponent(sessionTypeValue)}`; + return parsed.toString(); +} + +async function runLoad(options) { + const samples = []; + const queue = [...options.jobs]; + const workers = Array.from({ length: options.concurrency }, async () => { + while (queue.length > 0) { + const job = queue.shift(); + if (!job) continue; + const sample = await runSingleRequest({ ...options, job }); + samples.push(sample); + } + }); + await Promise.all(workers); + return samples.sort((left, right) => ( + left.pipeline_label.localeCompare(right.pipeline_label) || left.index - right.index + )); +} + +function expectedForIndex(prefix, index) { + return `${prefix}-${String(index + 1).padStart(4, "0")}`; +} + +function promptForIndex(template, expected) { + return template.replaceAll("{expected}", expected); +} + +function runSingleRequest({ + job, + timeoutMs, + promptTemplate, + stream, + failureSignals, +}) { + return new Promise((resolvePromise) => { + const expected = expectedForIndex(job.expectedPrefix, job.index); + const prompt = promptForIndex(promptTemplate, expected); + const sample = { + index: job.index, + pipeline_label: job.label, + pipeline_id: job.id, + pipeline_name: job.name, + status: "running", + ok: false, + expected_text: expected, + expected_prefix: job.expectedPrefix, + other_prefix: job.otherPrefix, + prompt, + response_text: "", + started_at: new Date().toISOString(), + started_epoch_ms: Date.now(), + connected_at: null, + connected_epoch_ms: null, + sent_at: null, + sent_epoch_ms: null, + first_assistant_event_at: null, + first_assistant_event_epoch_ms: null, + first_assistant_event_ms: null, + first_assistant_content_at: null, + first_assistant_content_epoch_ms: null, + first_assistant_content_ms: null, + first_response_at: null, + first_response_epoch_ms: null, + connected_ms: null, + first_response_ms: null, + response_duration_ms: null, + finished_at: null, + finished_epoch_ms: null, + event_count: 0, + same_pipeline_foreign_response_count: 0, + cross_pipeline_leak_count: 0, + last_foreign_response_text: "", + error: "", + close_code: null, + close_reason: "", + }; + let closed = false; + let connectedAt = 0; + let sentAt = 0; + const startedPerf = performance.now(); + let client = null; + const timer = setTimeout(() => { + finish("timeout", `Timed out after ${timeoutMs} ms.`); + }, timeoutMs); + + client = openRawWebSocket(job.wsUrl, { + onOpen() { + connectedAt = performance.now(); + const now = Date.now(); + sample.connected_at = new Date(now).toISOString(); + sample.connected_epoch_ms = now; + sample.connected_ms = rounded(connectedAt - startedPerf); + }, + onMessage(text) { + sample.event_count += 1; + let data; + try { + data = JSON.parse(String(text || "")); + } catch (error) { + finish("error", `Invalid WebSocket JSON: ${error.message}`); + return; + } + appendLine(paths.networkLog, JSON.stringify({ + pipeline_label: job.label, + request_index: job.index, + type: data.type, + session_type: data.session_type || "", + role: data.data?.role || "", + is_final: data.data?.is_final ?? null, + content_preview: redact(String(data.data?.content || data.message || "").slice(0, 200)), + })).catch(() => {}); + + if (data.type === "connected") { + sentAt = performance.now(); + const now = Date.now(); + sample.sent_at = new Date(now).toISOString(); + sample.sent_epoch_ms = now; + client.send(JSON.stringify({ + type: "message", + message: [{ type: "Plain", text: prompt }], + stream, + })); + return; + } + if (data.type === "error") { + finish("error", data.message || "WebSocket error message."); + return; + } + if (data.type !== "response" || data.data?.role !== "assistant") return; + + const content = String(data.data.content || ""); + markFirstAssistantEvent(sample, sentAt); + if (content) sample.response_text = content; + if (content) markFirstAssistantContent(sample, sentAt); + if (containsPipelineToken(content, job.otherPrefix)) { + sample.cross_pipeline_leak_count += 1; + finish("cross_pipeline_leak", `Pipeline ${job.label} received response from ${job.otherPrefix}: ${content}`); + return; + } + if (content.includes(expected) && sample.first_response_ms === null && sentAt > 0) { + const now = Date.now(); + sample.first_response_at = new Date(now).toISOString(); + sample.first_response_epoch_ms = now; + sample.first_response_ms = rounded(performance.now() - sentAt); + } + if (data.data.is_final === true) { + const ok = sample.response_text.includes(expected); + if (ok) { + if (sample.first_response_ms === null && sentAt > 0) { + const now = Date.now(); + sample.first_response_at = new Date(now).toISOString(); + sample.first_response_epoch_ms = now; + sample.first_response_ms = rounded(performance.now() - sentAt); + } + finish("pass", ""); + } else if (matchesFailureSignal(sample.response_text, failureSignals)) { + finish("app_error", `Assistant final response matched a failure signal: ${sample.response_text}`); + } else if (containsPipelineToken(sample.response_text, job.expectedPrefix)) { + sample.same_pipeline_foreign_response_count += 1; + sample.last_foreign_response_text = sample.response_text; + } else { + finish("mismatch", `Final assistant response did not include ${expected}: ${sample.response_text}`); + } + } + }, + onError(error) { + finish("connection_error", `WebSocket connection error: ${error.message}`); + }, + onClose(event) { + sample.close_code = event.code; + sample.close_reason = event.reason || ""; + if (!closed) finish("closed", `WebSocket closed before final assistant response: ${event.code}`); + }, + }); + + function finish(status, reason) { + if (closed) return; + closed = true; + clearTimeout(timer); + sample.status = status; + sample.ok = status === "pass"; + sample.error = status === "timeout" && sample.same_pipeline_foreign_response_count > 0 + ? `${reason || ""} Saw ${sample.same_pipeline_foreign_response_count} same-pipeline foreign assistant response(s); last=${sample.last_foreign_response_text}` + : reason || ""; + if (sentAt > 0) sample.response_duration_ms = rounded(performance.now() - sentAt); + else sample.response_duration_ms = rounded(performance.now() - startedPerf); + const now = Date.now(); + sample.finished_at = new Date(now).toISOString(); + sample.finished_epoch_ms = now; + try { + client?.close(); + } catch { + // Closing a failed socket should not hide the sample result. + } + resolvePromise(sample); + } + }); +} + +function markFirstAssistantEvent(sample, sentAt) { + if (sample.first_assistant_event_ms !== null || sentAt <= 0) return; + const now = Date.now(); + sample.first_assistant_event_at = new Date(now).toISOString(); + sample.first_assistant_event_epoch_ms = now; + sample.first_assistant_event_ms = rounded(performance.now() - sentAt); +} + +function markFirstAssistantContent(sample, sentAt) { + if (sample.first_assistant_content_ms !== null || sentAt <= 0) return; + const now = Date.now(); + sample.first_assistant_content_at = new Date(now).toISOString(); + sample.first_assistant_content_epoch_ms = now; + sample.first_assistant_content_ms = rounded(performance.now() - sentAt); +} + +function containsPipelineToken(text, prefix) { + const escaped = String(prefix).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`${escaped}-\\d{4}`).test(String(text || "")); +} + +function matchesFailureSignal(text, signals) { + const lower = String(text || "").toLowerCase(); + return signals.some((signal) => lower.includes(signal.toLowerCase())); +} + +function openRawWebSocket(wsUrl, handlers) { + const parsed = new URL(wsUrl); + const secure = parsed.protocol === "wss:"; + const port = Number(parsed.port || (secure ? 443 : 80)); + const host = parsed.hostname; + const path = `${parsed.pathname}${parsed.search}`; + const key = crypto.randomBytes(16).toString("base64"); + const socket = secure + ? tls.connect({ host, port, servername: host }) + : net.connect({ host, port }); + let opened = false; + let closed = false; + let buffer = Buffer.alloc(0); + + socket.setNoDelay(true); + socket.on("connect", () => { + const originProtocol = secure ? "https" : "http"; + const request = [ + `GET ${path} HTTP/1.1`, + `Host: ${parsed.host}`, + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Key: ${key}`, + "Sec-WebSocket-Version: 13", + `Origin: ${originProtocol}://${parsed.host}`, + "", + "", + ].join("\r\n"); + socket.write(request); + }); + socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + if (!opened) { + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const headerText = buffer.slice(0, headerEnd).toString("utf8"); + buffer = buffer.slice(headerEnd + 4); + if (!/^HTTP\/1\.1 101\b/i.test(headerText)) { + handlers.onError(new Error(`Handshake failed: ${headerText.split("\r\n")[0] || "missing status"}`)); + socket.destroy(); + return; + } + opened = true; + handlers.onOpen(); + } + processFrames(); + }); + socket.on("error", (error) => { + if (!closed) handlers.onError(error); + }); + socket.on("close", () => { + if (closed) return; + closed = true; + handlers.onClose({ code: null, reason: "" }); + }); + + function processFrames() { + while (true) { + const frame = readFrame(buffer); + if (!frame) return; + buffer = buffer.slice(frame.consumed); + if (frame.opcode === 0x1) { + handlers.onMessage(frame.payload.toString("utf8")); + } else if (frame.opcode === 0x8) { + const code = frame.payload.length >= 2 ? frame.payload.readUInt16BE(0) : null; + const reason = frame.payload.length > 2 ? frame.payload.slice(2).toString("utf8") : ""; + closed = true; + handlers.onClose({ code, reason }); + socket.end(); + return; + } else if (frame.opcode === 0x9) { + writeFrame(socket, 0xA, frame.payload); + } + } + } + + return { + send(text) { + if (closed || !opened) return; + writeFrame(socket, 0x1, Buffer.from(text, "utf8")); + }, + close() { + if (closed) return; + closed = true; + if (!socket.destroyed) { + if (opened) writeFrame(socket, 0x8, Buffer.alloc(0)); + setTimeout(() => socket.end(), 50).unref(); + } + }, + }; +} + +function readFrame(buffer) { + if (buffer.length < 2) return null; + const first = buffer[0]; + const second = buffer[1]; + const opcode = first & 0x0f; + const masked = Boolean(second & 0x80); + let length = second & 0x7f; + let offset = 2; + if (length === 126) { + if (buffer.length < offset + 2) return null; + length = buffer.readUInt16BE(offset); + offset += 2; + } else if (length === 127) { + if (buffer.length < offset + 8) return null; + const high = buffer.readUInt32BE(offset); + const low = buffer.readUInt32BE(offset + 4); + length = high * 2 ** 32 + low; + offset += 8; + } + let mask = null; + if (masked) { + if (buffer.length < offset + 4) return null; + mask = buffer.slice(offset, offset + 4); + offset += 4; + } + if (buffer.length < offset + length) return null; + let payload = buffer.slice(offset, offset + length); + if (mask) { + payload = Buffer.from(payload); + for (let index = 0; index < payload.length; index += 1) { + payload[index] ^= mask[index % 4]; + } + } + return { + opcode, + payload, + consumed: offset + length, + }; +} + +function writeFrame(socket, opcode, payload) { + const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload || ""); + const mask = crypto.randomBytes(4); + const headerLength = body.length < 126 ? 2 : body.length <= 0xffff ? 4 : 10; + const header = Buffer.alloc(headerLength); + header[0] = 0x80 | opcode; + if (body.length < 126) { + header[1] = 0x80 | body.length; + } else if (body.length <= 0xffff) { + header[1] = 0x80 | 126; + header.writeUInt16BE(body.length, 2); + } else { + header[1] = 0x80 | 127; + header.writeUInt32BE(Math.floor(body.length / 2 ** 32), 2); + header.writeUInt32BE(body.length >>> 0, 6); + } + const masked = Buffer.from(body); + for (let index = 0; index < masked.length; index += 1) { + masked[index] ^= mask[index % 4]; + } + socket.write(Buffer.concat([header, mask, masked])); +} + +function buildMetrics({ samples, requestsPerPipeline, concurrency, timeoutMs, loadDurationMs, backendUrl, sessionType, fakeProviderState }) { + const okSamples = samples.filter((sample) => sample.ok); + const statusCounts = {}; + const byPipeline = {}; + for (const sample of samples) { + statusCounts[sample.status] = (statusCounts[sample.status] || 0) + 1; + if (!byPipeline[sample.pipeline_label]) { + byPipeline[sample.pipeline_label] = { + ok_count: 0, + error_count: 0, + cross_pipeline_leak_count: 0, + timeout_count: 0, + }; + } + if (sample.ok) byPipeline[sample.pipeline_label].ok_count += 1; + else byPipeline[sample.pipeline_label].error_count += 1; + byPipeline[sample.pipeline_label].cross_pipeline_leak_count += sample.cross_pipeline_leak_count || 0; + if (sample.status === "timeout") byPipeline[sample.pipeline_label].timeout_count += 1; + } + const errorCount = samples.length - okSamples.length; + return { + probe: caseId, + backend_url: backendUrl, + session_type: sessionType, + requests_per_pipeline: requestsPerPipeline, + total_requests: requestsPerPipeline * 2, + completed_requests: samples.length, + concurrency, + timeout_ms: timeoutMs, + ok_count: okSamples.length, + error_count: errorCount, + timeout_count: samples.filter((sample) => sample.status === "timeout").length, + cross_pipeline_leak_count: samples.reduce((count, sample) => count + (sample.cross_pipeline_leak_count || 0), 0), + error_rate: samples.length === 0 ? 1 : rounded(errorCount / samples.length), + load_duration_ms: rounded(loadDurationMs), + throughput_rps: loadDurationMs <= 0 ? 0 : rounded(okSamples.length / (loadDurationMs / 1000)), + status_counts: statusCounts, + by_pipeline: byPipeline, + connected_ms: stats(samples.map((sample) => sample.connected_ms).filter(Number.isFinite)), + first_assistant_event_ms: stats(samples.map((sample) => sample.first_assistant_event_ms).filter(Number.isFinite)), + first_assistant_content_ms: stats(samples.map((sample) => sample.first_assistant_content_ms).filter(Number.isFinite)), + first_response_ms: stats(okSamples.map((sample) => sample.first_response_ms).filter(Number.isFinite)), + response_duration_ms: stats(okSamples.map((sample) => sample.response_duration_ms).filter(Number.isFinite)), + fake_provider: summarizeFakeProviderState(fakeProviderState), + provider_timing: buildProviderTimingMetrics(samples, fakeProviderState), + samples, + }; +} + +function buildThresholds(metrics) { + return { + cross_pipeline_leak_count: { + actual: metrics.cross_pipeline_leak_count, + max: 0, + pass: metrics.cross_pipeline_leak_count === 0, + }, + error_rate: { + actual: metrics.error_rate, + max: maxErrorRate, + pass: metrics.error_rate <= maxErrorRate, + }, + response_p95_ms: { + actual: metrics.response_duration_ms.p95, + max: responseP95BudgetMs, + pass: metrics.ok_count > 0 && metrics.response_duration_ms.p95 <= responseP95BudgetMs, + }, + }; +} + +function positiveInteger(value, fallback) { + const parsed = Number.parseInt(String(value || ""), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function positiveNumber(value, fallback) { + const parsed = Number(value || ""); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +function bool(value, fallback) { + if (value === undefined || value === "") return fallback; + if (/^(1|true|yes|on)$/i.test(String(value))) return true; + if (/^(0|false|no|off)$/i.test(String(value))) return false; + return fallback; +} + +function textList(value) { + return String(value || "") + .split(/\r?\n|,/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function rounded(value) { + return Number(value.toFixed(3)); +} + +function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1); + return rounded(sorted[index]); +} + +function stats(values) { + if (values.length === 0) return { min: 0, p50: 0, p95: 0, p99: 0, max: 0 }; + return { + min: rounded(Math.min(...values)), + p50: percentile(values, 50), + p95: percentile(values, 95), + p99: percentile(values, 99), + max: rounded(Math.max(...values)), + }; +} + +function looksLikeEnvIssue(error) { + const message = String(error?.message || error || ""); + return /fetch failed|ECONNREFUSED|ENOTFOUND|LANGBOT_.*not configured|Could not read recovery_key|Backend did not respond/i.test(message); +} + +function safeReason(value) { + return redact(String(value || "")).slice(0, 1000); +} diff --git a/skills/skills/langbot-testing/probes/langbot-fault-taxonomy-contract.mjs b/skills/skills/langbot-testing/probes/langbot-fault-taxonomy-contract.mjs new file mode 100755 index 000000000..8c9628e58 --- /dev/null +++ b/skills/skills/langbot-testing/probes/langbot-fault-taxonomy-contract.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { env, exit } from "node:process"; + +function pad(value, size = 2) { + return String(value).padStart(size, "0"); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +const scenarios = [ + { + id: "provider-timeout", + target: "provider", + injected_fault: "fake provider request exceeds the configured timeout", + expected_status: "env_issue", + recovery_check: "provider route is reachable or the case remains outside product pass/fail", + cleanup: "stop fake provider or reset proxy route", + }, + { + id: "plugin-runtime-disconnect", + target: "plugin-runtime", + injected_fault: "runtime control channel disconnects during an action", + expected_status: "fail", + recovery_check: "runtime reconnects and a deterministic plugin action succeeds", + cleanup: "restart the local plugin runtime process", + }, + { + id: "mcp-stdio-server-exit", + target: "mcp", + injected_fault: "stdio server exits mid-call", + expected_status: "fail", + recovery_check: "server can be registered again and exposes the expected tool", + cleanup: "remove temporary MCP server registration", + }, + { + id: "operator-missing-login", + target: "webui", + injected_fault: "browser profile is not authenticated", + expected_status: "blocked", + recovery_check: "authenticated profile can open the same WebUI origin", + cleanup: "no product cleanup; refresh local login state", + }, + { + id: "transient-marketplace-timeout", + target: "marketplace", + injected_fault: "marketplace request times out once and then succeeds", + expected_status: "flaky", + recovery_check: "rerun passes with the same product revision and no code change", + cleanup: "clear retry-only evidence and keep the run classified as flaky", + }, +]; + +function validateScenario(scenario) { + const missing = ["id", "target", "injected_fault", "expected_status", "recovery_check", "cleanup"] + .filter((key) => !scenario[key]); + const allowedStatuses = new Set(["pass", "fail", "blocked", "env_issue", "flaky"]); + return { + id: scenario.id, + pass: missing.length === 0 && allowedStatuses.has(scenario.expected_status), + missing, + expected_status: scenario.expected_status, + }; +} + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "langbot-fault-taxonomy-contract"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + + const startedAt = new Date(); + const validations = scenarios.map(validateScenario); + const statusCounts = {}; + for (const scenario of scenarios) { + statusCounts[scenario.expected_status] = (statusCounts[scenario.expected_status] || 0) + 1; + } + const metrics = { + probe: caseId, + scenario_count: scenarios.length, + status_counts: statusCounts, + scenarios, + validations, + }; + const thresholds = { + scenario_count: { actual: scenarios.length, min: 5, pass: scenarios.length >= 5 }, + invalid_scenario_count: { + actual: validations.filter((item) => !item.pass).length, + max: 0, + pass: validations.every((item) => item.pass), + }, + cleanup_declared_count: { + actual: scenarios.filter((item) => item.cleanup).length, + min: scenarios.length, + pass: scenarios.every((item) => item.cleanup), + }, + }; + const status = Object.values(thresholds).every((item) => item.pass) ? "pass" : "fail"; + const metricsPath = join(evidenceDir, "metrics.json"); + const faultModelPath = join(evidenceDir, "fault-model.json"); + const automationResultPath = join(evidenceDir, "automation-result.json"); + const resultPath = join(evidenceDir, "result.json"); + + await writeFile(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`, "utf8"); + await writeFile(faultModelPath, `${JSON.stringify({ scenarios }, null, 2)}\n`, "utf8"); + + const finishedAt = new Date(); + const result = { + source: "automation", + case_id: caseId, + run_id: runId, + status, + reason: status === "pass" + ? "Fault taxonomy contract declares status, recovery, and cleanup for every scenario." + : "Fault taxonomy contract is missing required scenario fields.", + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: finishedAt.toISOString(), + finished_at_local: localIsoWithOffset(finishedAt), + duration_ms: finishedAt.getTime() - startedAt.getTime(), + metrics_summary: { + scenario_count: metrics.scenario_count, + status_counts: metrics.status_counts, + invalid_scenario_count: thresholds.invalid_scenario_count.actual, + }, + thresholds_summary: thresholds, + artifacts: { + metrics_json: metricsPath, + fault_model_json: faultModelPath, + automation_result_json: automationResultPath, + result_json: resultPath, + }, + evidence_collected: ["metrics", "filesystem"], + }; + + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultPath, resultText, "utf8"); + await writeFile(resultPath, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + exit(status === "pass" ? 0 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/langbot-live-backend-latency.mjs b/skills/skills/langbot-testing/probes/langbot-live-backend-latency.mjs new file mode 100755 index 000000000..747c84c6a --- /dev/null +++ b/skills/skills/langbot-testing/probes/langbot-live-backend-latency.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { env, exit } from "node:process"; + +function pad(value, size = 2) { + return String(value).padStart(size, "0"); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1); + return Number(sorted[index].toFixed(3)); +} + +function stats(values) { + if (values.length === 0) return { min: 0, p50: 0, p95: 0, p99: 0, max: 0 }; + return { + min: Number(Math.min(...values).toFixed(3)), + p50: percentile(values, 50), + p95: percentile(values, 95), + p99: percentile(values, 99), + max: Number(Math.max(...values).toFixed(3)), + }; +} + +function parseJsonList(value, fallback) { + if (!value) return fallback; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) && parsed.every((item) => typeof item === "string") ? parsed : fallback; + } catch { + return fallback; + } +} + +function joinUrl(baseUrl, path) { + const base = baseUrl.replace(/\/+$/, ""); + const suffix = path.startsWith("/") ? path : `/${path}`; + return `${base}${suffix}`; +} + +async function fetchOnce(url, timeoutMs) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const started = performance.now(); + try { + const response = await fetch(url, { method: "GET", signal: controller.signal }); + await response.arrayBuffer(); + const latencyMs = performance.now() - started; + return { + url, + ok: response.status < 500, + status: response.status, + latency_ms: Number(latencyMs.toFixed(3)), + error: "", + }; + } catch (error) { + const latencyMs = performance.now() - started; + return { + url, + ok: false, + status: 0, + latency_ms: Number(latencyMs.toFixed(3)), + error: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timeout); + } +} + +async function runBatches(urls, totalRequests, concurrency, timeoutMs) { + const queue = Array.from({ length: totalRequests }, (_, index) => urls[index % urls.length]); + const results = []; + while (queue.length > 0) { + const batch = queue.splice(0, concurrency); + results.push(...await Promise.all(batch.map((url) => fetchOnce(url, timeoutMs)))); + } + return results; +} + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "langbot-live-backend-latency"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + + const startedAt = new Date(); + const backendUrl = env.LANGBOT_BACKEND_URL || ""; + const endpoints = parseJsonList(env.LANGBOT_PERF_ENDPOINTS_JSON, ["/healthz"]); + const totalRequests = Number(env.LANGBOT_PERF_REQUESTS || "12"); + const concurrency = Number(env.LANGBOT_PERF_CONCURRENCY || "2"); + const timeoutMs = Number(env.LANGBOT_PERF_TIMEOUT_MS || "5000"); + const p95BudgetMs = Number(env.LANGBOT_PERF_BACKEND_P95_MS || "1000"); + const maxErrorRate = Number(env.LANGBOT_PERF_MAX_ERROR_RATE || "0"); + const metricsPath = join(evidenceDir, "metrics.json"); + const networkLogPath = join(evidenceDir, "network.log"); + const automationResultPath = join(evidenceDir, "automation-result.json"); + const resultPath = join(evidenceDir, "result.json"); + + let status = "fail"; + let reason = ""; + let results = []; + if (!backendUrl) { + status = "env_issue"; + reason = "LANGBOT_BACKEND_URL is not configured."; + } else { + const urls = endpoints.map((path) => joinUrl(backendUrl, path)); + results = await runBatches(urls, totalRequests, concurrency, timeoutMs); + const okCount = results.filter((item) => item.ok).length; + const errorCount = results.length - okCount; + const errorRate = results.length === 0 ? 1 : errorCount / results.length; + const latencies = results.filter((item) => item.ok).map((item) => item.latency_ms); + const latencyStats = stats(latencies); + const allConnectionFailures = results.length > 0 && results.every((item) => item.status === 0); + if (allConnectionFailures) { + status = "env_issue"; + reason = `Backend did not respond at ${backendUrl}.`; + } else if (latencyStats.p95 <= p95BudgetMs && errorRate <= maxErrorRate) { + status = "pass"; + reason = "Live backend latency probe passed all thresholds."; + } else { + status = "fail"; + reason = "Live backend latency probe breached latency or error-rate thresholds."; + } + } + + const statusCounts = {}; + for (const item of results) { + const key = item.status === 0 ? "network_error" : String(item.status); + statusCounts[key] = (statusCounts[key] || 0) + 1; + } + const okResults = results.filter((item) => item.ok); + const metrics = { + probe: caseId, + backend_url: backendUrl, + endpoints, + total_requests: totalRequests, + concurrency, + timeout_ms: timeoutMs, + ok_count: okResults.length, + error_count: results.length - okResults.length, + error_rate: results.length === 0 ? 1 : Number(((results.length - okResults.length) / results.length).toFixed(4)), + latency_ms: stats(okResults.map((item) => item.latency_ms)), + status_counts: statusCounts, + }; + const thresholds = { + backend_p95_ms: { actual: metrics.latency_ms.p95, max: p95BudgetMs, pass: metrics.latency_ms.p95 <= p95BudgetMs }, + error_rate: { actual: metrics.error_rate, max: maxErrorRate, pass: metrics.error_rate <= maxErrorRate }, + }; + + await writeFile(metricsPath, `${JSON.stringify({ ...metrics, samples: results }, null, 2)}\n`, "utf8"); + await writeFile(networkLogPath, results.map((item) => JSON.stringify(item)).join("\n") + (results.length > 0 ? "\n" : ""), "utf8"); + + const finishedAt = new Date(); + const result = { + source: "automation", + case_id: caseId, + run_id: runId, + status, + reason, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: finishedAt.toISOString(), + finished_at_local: localIsoWithOffset(finishedAt), + duration_ms: finishedAt.getTime() - startedAt.getTime(), + url: backendUrl, + metrics_summary: { + requests: metrics.total_requests, + concurrency: metrics.concurrency, + ok_count: metrics.ok_count, + error_rate: metrics.error_rate, + latency_p50_ms: metrics.latency_ms.p50, + latency_p95_ms: metrics.latency_ms.p95, + status_counts: metrics.status_counts, + }, + thresholds_summary: thresholds, + artifacts: { + metrics_json: metricsPath, + network_log: networkLogPath, + automation_result_json: automationResultPath, + result_json: resultPath, + }, + evidence_collected: ["metrics", "network", "api_diagnostic", "filesystem"], + }; + + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultPath, resultText, "utf8"); + await writeFile(resultPath, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + exit(status === "pass" ? 0 : status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/langbot-live-backend-log-health.mjs b/skills/skills/langbot-testing/probes/langbot-live-backend-log-health.mjs new file mode 100755 index 000000000..38a31c389 --- /dev/null +++ b/skills/skills/langbot-testing/probes/langbot-live-backend-log-health.mjs @@ -0,0 +1,205 @@ +#!/usr/bin/env node + +import { existsSync, readdirSync, statSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { env, exit } from "node:process"; + +function pad(value, size = 2) { + return String(value).padStart(size, "0"); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function repoRootFromEnv(root) { + return env.LANGBOT_REPO ? resolve(env.LANGBOT_REPO) : resolve(root, ".."); +} + +function latestBackendLog(root) { + const explicit = env.LANGBOT_BACKEND_LOG; + if (explicit) return resolve(explicit); + + const logsDir = join(repoRootFromEnv(root), "data", "logs"); + if (!existsSync(logsDir)) return ""; + const candidates = readdirSync(logsDir) + .filter((name) => /^langbot-.*\.log$/.test(name)) + .map((name) => join(logsDir, name)) + .filter((path) => { + try { + return statSync(path).isFile(); + } catch { + return false; + } + }) + .sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs); + return candidates[0] || ""; +} + +function parseSince(startedAt) { + if (env.LANGBOT_BACKEND_LOG_SINCE) return new Date(env.LANGBOT_BACKEND_LOG_SINCE); + const lookbackSeconds = Number(env.LANGBOT_BACKEND_LOG_LOOKBACK_SECONDS || "300"); + return new Date(startedAt.getTime() - lookbackSeconds * 1000); +} + +function parseTimestamp(line, year) { + const localMatch = line.match(/^\[(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})\]/); + if (localMatch) { + const [, month, day, hour, minute, second, millisecond] = localMatch; + return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.${millisecond}+08:00`); + } + + const accessMatch = line.match(/^\[(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) ([+-]\d{4})\]/); + if (accessMatch) { + const [, fullYear, month, day, hour, minute, second, offset] = accessMatch; + const normalizedOffset = `${offset.slice(0, 3)}:${offset.slice(3)}`; + return new Date(`${fullYear}-${month}-${day}T${hour}:${minute}:${second}${normalizedOffset}`); + } + + return null; +} + +function findingForLine(line, number) { + const rules = [ + { severity: "fail", kind: "python_traceback", pattern: /\bTraceback(?: \(most recent call last\))?/i }, + { severity: "fail", kind: "unretrieved_task_exception", pattern: /Task exception was never retrieved/i }, + { severity: "fail", kind: "unawaited_coroutine", pattern: /RuntimeWarning:\s+coroutine .* was never awaited/i }, + { severity: "fail", kind: "unclosed_client_session", pattern: /Unclosed client session/i }, + { severity: "fail", kind: "unclosed_connector", pattern: /Unclosed connector/i }, + { severity: "fail", kind: "import_error", pattern: /\bImportError\b/i }, + { severity: "fail", kind: "error_log", pattern: /\b(?:ERROR|CRITICAL)\b/ }, + { severity: "warning", kind: "warning_log", pattern: /\bWARNING\b/ }, + ]; + + for (const rule of rules) { + if (rule.pattern.test(line)) { + return { + severity: rule.severity, + kind: rule.kind, + line: number, + excerpt: line, + }; + } + } + return null; +} + +function scanLines(text, since, year) { + const findings = []; + const scanned = []; + let includeContinuation = false; + const lines = text.split(/\r?\n/); + for (const [index, line] of lines.entries()) { + const number = index + 1; + const timestamp = parseTimestamp(line, year); + if (timestamp) includeContinuation = timestamp >= since; + if (!includeContinuation) continue; + scanned.push({ number, text: line }); + const finding = findingForLine(line, number); + if (finding) findings.push(finding); + } + return { findings, scanned, total_lines: lines.length }; +} + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "langbot-live-backend-log-health"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + + const startedAt = new Date(); + const since = parseSince(startedAt); + const logPath = latestBackendLog(root); + const metricsPath = join(evidenceDir, "metrics.json"); + const findingsPath = join(evidenceDir, "findings.json"); + const scannedLogPath = join(evidenceDir, "scanned-backend.log"); + const automationResultPath = join(evidenceDir, "automation-result.json"); + const resultPath = join(evidenceDir, "result.json"); + + let status = "fail"; + let reason = ""; + let scan = { findings: [], scanned: [], total_lines: 0 }; + if (!logPath || !existsSync(logPath)) { + status = "env_issue"; + reason = "No LangBot backend log file was found. Set LANGBOT_BACKEND_LOG or LANGBOT_REPO."; + } else { + const text = await readFile(logPath, "utf8"); + scan = scanLines(text, since, startedAt.getFullYear()); + const failCount = scan.findings.filter((item) => item.severity === "fail").length; + status = failCount === 0 ? "pass" : "fail"; + reason = status === "pass" + ? "Live backend log health passed; no fail-severity findings in the scanned window." + : "Live backend log health found fail-severity backend log findings."; + } + + const warningCount = scan.findings.filter((item) => item.severity === "warning").length; + const failCount = scan.findings.filter((item) => item.severity === "fail").length; + const metrics = { + probe: caseId, + backend_log: logPath, + since: since.toISOString(), + scanned_line_count: scan.scanned.length, + total_line_count: scan.total_lines, + fail_count: failCount, + warning_count: warningCount, + finding_count: scan.findings.length, + }; + const thresholds = { + fail_count: { actual: failCount, max: 0, pass: failCount === 0 }, + }; + + await writeFile(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`, "utf8"); + await writeFile(findingsPath, `${JSON.stringify(scan.findings, null, 2)}\n`, "utf8"); + await writeFile(scannedLogPath, scan.scanned.map((item) => `${item.number}: ${item.text}`).join("\n") + (scan.scanned.length > 0 ? "\n" : ""), "utf8"); + + const finishedAt = new Date(); + const result = { + source: "automation", + case_id: caseId, + run_id: runId, + status, + reason, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: finishedAt.toISOString(), + finished_at_local: localIsoWithOffset(finishedAt), + duration_ms: finishedAt.getTime() - startedAt.getTime(), + url: logPath, + metrics_summary: { + scanned_line_count: metrics.scanned_line_count, + fail_count: metrics.fail_count, + warning_count: metrics.warning_count, + finding_count: metrics.finding_count, + }, + thresholds_summary: thresholds, + artifacts: { + metrics_json: metricsPath, + findings_json: findingsPath, + scanned_backend_log: scannedLogPath, + automation_result_json: automationResultPath, + result_json: resultPath, + }, + evidence_collected: ["metrics", "backend_log", "filesystem"], + }; + + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultPath, resultText, "utf8"); + await writeFile(resultPath, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + exit(status === "pass" ? 0 : status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/langbot-live-control-plane-api.mjs b/skills/skills/langbot-testing/probes/langbot-live-control-plane-api.mjs new file mode 100755 index 000000000..8232d1fc3 --- /dev/null +++ b/skills/skills/langbot-testing/probes/langbot-live-control-plane-api.mjs @@ -0,0 +1,311 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { env, exit } from "node:process"; + +function pad(value, size = 2) { + return String(value).padStart(size, "0"); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1); + return Number(sorted[index].toFixed(3)); +} + +function stats(values) { + if (values.length === 0) return { min: 0, p50: 0, p95: 0, p99: 0, max: 0 }; + return { + min: Number(Math.min(...values).toFixed(3)), + p50: percentile(values, 50), + p95: percentile(values, 95), + p99: percentile(values, 99), + max: Number(Math.max(...values).toFixed(3)), + }; +} + +function joinUrl(baseUrl, path) { + const base = baseUrl.replace(/\/+$/, ""); + const suffix = path.startsWith("/") ? path : `/${path}`; + return `${base}${suffix}`; +} + +function parseJsonObject(value, fallback) { + if (!value) return fallback; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback; + } catch { + return fallback; + } +} + +function controlPlaneEndpoints() { + return [ + { + id: "healthz", + path: "/healthz", + expected_status: 200, + expected_code: 0, + p95_budget_ms: Number(env.LANGBOT_PERF_HEALTHZ_P95_MS || "500"), + required_data_fields: [], + }, + { + id: "system_info", + path: "/api/v1/system/info", + expected_status: 200, + expected_code: 0, + p95_budget_ms: Number(env.LANGBOT_PERF_SYSTEM_INFO_P95_MS || "1000"), + required_data_fields: ["version", "edition", "enable_marketplace"], + }, + ]; +} + +async function fetchEndpoint(backendUrl, endpoint, timeoutMs) { + const url = joinUrl(backendUrl, endpoint.path); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const started = performance.now(); + let bodyText = ""; + let json = null; + let jsonValid = false; + let error = ""; + + try { + const response = await fetch(url, { + method: "GET", + headers: { "accept": "application/json" }, + signal: controller.signal, + }); + bodyText = await response.text(); + try { + json = bodyText ? JSON.parse(bodyText) : null; + jsonValid = json !== null; + } catch (parseError) { + error = parseError instanceof Error ? parseError.message : String(parseError); + } + + const data = json && typeof json === "object" && json.data && typeof json.data === "object" ? json.data : {}; + const missingFields = endpoint.required_data_fields.filter((field) => !(field in data)); + const statusOk = response.status === endpoint.expected_status; + const codeOk = !json || typeof json !== "object" ? false : json.code === endpoint.expected_code; + const shapeOk = jsonValid && missingFields.length === 0; + const latencyMs = performance.now() - started; + return { + endpoint_id: endpoint.id, + path: endpoint.path, + url, + status: response.status, + ok: statusOk && codeOk && shapeOk, + status_ok: statusOk, + code_ok: codeOk, + json_valid: jsonValid, + missing_fields: missingFields, + response_code: json && typeof json === "object" ? json.code : null, + latency_ms: Number(latencyMs.toFixed(3)), + error, + }; + } catch (fetchError) { + const latencyMs = performance.now() - started; + return { + endpoint_id: endpoint.id, + path: endpoint.path, + url, + status: 0, + ok: false, + status_ok: false, + code_ok: false, + json_valid: false, + missing_fields: endpoint.required_data_fields, + response_code: null, + latency_ms: Number(latencyMs.toFixed(3)), + error: fetchError instanceof Error ? fetchError.message : String(fetchError), + }; + } finally { + clearTimeout(timeout); + } +} + +async function runBatches(backendUrl, endpoints, totalRequests, concurrency, timeoutMs) { + const queue = Array.from({ length: totalRequests }, (_, index) => endpoints[index % endpoints.length]); + const results = []; + while (queue.length > 0) { + const batch = queue.splice(0, concurrency); + results.push(...await Promise.all(batch.map((endpoint) => fetchEndpoint(backendUrl, endpoint, timeoutMs)))); + } + return results; +} + +function endpointMetrics(endpoints, results) { + return Object.fromEntries(endpoints.map((endpoint) => { + const samples = results.filter((item) => item.endpoint_id === endpoint.id); + const okSamples = samples.filter((item) => item.ok); + return [ + endpoint.id, + { + path: endpoint.path, + requests: samples.length, + ok_count: okSamples.length, + error_rate: samples.length === 0 ? 1 : Number(((samples.length - okSamples.length) / samples.length).toFixed(4)), + latency_ms: stats(okSamples.map((item) => item.latency_ms)), + p95_budget_ms: endpoint.p95_budget_ms, + }, + ]; + })); +} + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "langbot-live-control-plane-api"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + + const startedAt = new Date(); + const backendUrl = env.LANGBOT_BACKEND_URL || ""; + const endpoints = controlPlaneEndpoints(); + const configuredBudgets = parseJsonObject(env.LANGBOT_CONTROL_PLANE_P95_BUDGETS_JSON, {}); + for (const endpoint of endpoints) { + const budget = configuredBudgets[endpoint.id]; + if (typeof budget === "number" && Number.isFinite(budget)) endpoint.p95_budget_ms = budget; + } + const totalRequests = Number(env.LANGBOT_CONTROL_PLANE_REQUESTS || "20"); + const concurrency = Number(env.LANGBOT_CONTROL_PLANE_CONCURRENCY || "4"); + const timeoutMs = Number(env.LANGBOT_CONTROL_PLANE_TIMEOUT_MS || "5000"); + const maxErrorRate = Number(env.LANGBOT_CONTROL_PLANE_MAX_ERROR_RATE || "0"); + const metricsPath = join(evidenceDir, "metrics.json"); + const endpointsPath = join(evidenceDir, "endpoints.json"); + const networkLogPath = join(evidenceDir, "network.log"); + const automationResultPath = join(evidenceDir, "automation-result.json"); + const resultPath = join(evidenceDir, "result.json"); + + let status = "fail"; + let reason = ""; + let results = []; + if (!backendUrl) { + status = "env_issue"; + reason = "LANGBOT_BACKEND_URL is not configured."; + } else { + results = await runBatches(backendUrl, endpoints, totalRequests, concurrency, timeoutMs); + const allConnectionFailures = results.length > 0 && results.every((item) => item.status === 0); + if (allConnectionFailures) { + status = "env_issue"; + reason = `Backend did not respond at ${backendUrl}.`; + } + } + + const okResults = results.filter((item) => item.ok); + const statusCounts = {}; + for (const item of results) { + const key = item.status === 0 ? "network_error" : String(item.status); + statusCounts[key] = (statusCounts[key] || 0) + 1; + } + const perEndpoint = endpointMetrics(endpoints, results); + const responseShapeFailures = results.filter((item) => !item.json_valid || item.missing_fields.length > 0 || !item.code_ok).length; + const errorRate = results.length === 0 ? 1 : Number(((results.length - okResults.length) / results.length).toFixed(4)); + const thresholds = { + error_rate: { actual: errorRate, max: maxErrorRate, pass: errorRate <= maxErrorRate }, + response_shape_failures: { actual: responseShapeFailures, max: 0, pass: responseShapeFailures === 0 }, + }; + for (const endpoint of endpoints) { + const actual = perEndpoint[endpoint.id].latency_ms.p95; + thresholds[`${endpoint.id}_p95_ms`] = { + actual, + max: endpoint.p95_budget_ms, + pass: actual <= endpoint.p95_budget_ms, + }; + } + + if (status !== "env_issue") { + const passed = Object.values(thresholds).every((item) => item.pass); + status = passed ? "pass" : "fail"; + reason = passed + ? "Live control-plane API probe passed all thresholds." + : "Live control-plane API probe breached shape, latency, or error-rate thresholds."; + } + + const metrics = { + probe: caseId, + backend_url: backendUrl, + total_requests: totalRequests, + concurrency, + timeout_ms: timeoutMs, + ok_count: okResults.length, + error_count: results.length - okResults.length, + error_rate: errorRate, + status_counts: statusCounts, + response_shape_failures: responseShapeFailures, + endpoints: perEndpoint, + }; + + await writeFile(metricsPath, `${JSON.stringify({ ...metrics, samples: results }, null, 2)}\n`, "utf8"); + await writeFile(endpointsPath, `${JSON.stringify(endpoints, null, 2)}\n`, "utf8"); + await writeFile(networkLogPath, results.map((item) => JSON.stringify(item)).join("\n") + (results.length > 0 ? "\n" : ""), "utf8"); + + const finishedAt = new Date(); + const result = { + source: "automation", + case_id: caseId, + run_id: runId, + status, + reason, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: finishedAt.toISOString(), + finished_at_local: localIsoWithOffset(finishedAt), + duration_ms: finishedAt.getTime() - startedAt.getTime(), + url: backendUrl, + metrics_summary: { + requests: metrics.total_requests, + concurrency: metrics.concurrency, + ok_count: metrics.ok_count, + error_rate: metrics.error_rate, + response_shape_failures: metrics.response_shape_failures, + endpoints: Object.fromEntries(Object.entries(metrics.endpoints).map(([id, value]) => [ + id, + { + path: value.path, + ok_count: value.ok_count, + error_rate: value.error_rate, + latency_p50_ms: value.latency_ms.p50, + latency_p95_ms: value.latency_ms.p95, + }, + ])), + status_counts: metrics.status_counts, + }, + thresholds_summary: thresholds, + artifacts: { + metrics_json: metricsPath, + endpoints_json: endpointsPath, + network_log: networkLogPath, + automation_result_json: automationResultPath, + result_json: resultPath, + }, + evidence_collected: ["metrics", "network", "api_diagnostic", "filesystem"], + }; + + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultPath, resultText, "utf8"); + await writeFile(resultPath, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + exit(status === "pass" ? 0 : status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/langbot-overhead-accounting-contract.mjs b/skills/skills/langbot-testing/probes/langbot-overhead-accounting-contract.mjs new file mode 100755 index 000000000..5338df003 --- /dev/null +++ b/skills/skills/langbot-testing/probes/langbot-overhead-accounting-contract.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { env, exit } from "node:process"; + +function pad(value, size = 2) { + return String(value).padStart(size, "0"); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1); + return Number(sorted[index].toFixed(3)); +} + +function stats(values) { + return { + min: Number(Math.min(...values).toFixed(3)), + p50: percentile(values, 50), + p95: percentile(values, 95), + p99: percentile(values, 99), + max: Number(Math.max(...values).toFixed(3)), + }; +} + +function threshold(actual, limit, operator) { + const pass = operator === "<=" ? actual <= limit : actual >= limit; + return { actual, [operator === "<=" ? "max" : "min"]: limit, pass }; +} + +function makeSample(index) { + const ingress = 1 + (index % 5) * 0.22; + const pipeline = 2.8 + (index % 7) * 0.31; + const persistence = 1.1 + (index % 4) * 0.2; + const pluginIpc = 1.9 + (index % 6) * 0.27; + const rag = index % 3 === 0 ? 4.4 : 0.8 + (index % 5) * 0.18; + const streaming = 1.5 + (index % 8) * 0.24; + const provider = 80 + (index % 13) * 11; + const externalTool = index % 4 === 0 ? 25 + (index % 9) * 3 : 0; + const network = 8 + (index % 10) * 1.7; + const overhead = ingress + pipeline + persistence + pluginIpc + rag + streaming; + const external = provider + externalTool + network; + const total = overhead + external; + return { + index, + segments_ms: { + ingress, + pipeline, + persistence, + plugin_ipc: pluginIpc, + rag, + streaming, + provider, + external_tool: externalTool, + network, + }, + langbot_overhead_ms: Number(overhead.toFixed(3)), + external_latency_ms: Number(external.toFixed(3)), + e2e_latency_ms: Number(total.toFixed(3)), + accounting_gap_ms: Number((total - external - overhead).toFixed(6)), + }; +} + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "langbot-overhead-accounting-contract"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + + const startedAt = new Date(); + const sampleCount = Number(env.LANGBOT_PERF_CONTRACT_SAMPLES || "80"); + const overheadP95BudgetMs = Number(env.LANGBOT_PERF_OVERHEAD_P95_MS || "25"); + const samples = Array.from({ length: sampleCount }, (_, index) => makeSample(index)); + const overheads = samples.map((sample) => sample.langbot_overhead_ms); + const e2e = samples.map((sample) => sample.e2e_latency_ms); + const external = samples.map((sample) => sample.external_latency_ms); + const gaps = samples.map((sample) => Math.abs(sample.accounting_gap_ms)); + const memory = process.memoryUsage(); + + const metrics = { + probe: caseId, + sample_count: sampleCount, + langbot_overhead_ms: stats(overheads), + e2e_latency_ms: stats(e2e), + external_latency_ms: stats(external), + accounting_gap_max_ms: Number(Math.max(...gaps).toFixed(6)), + samples, + }; + const thresholds = { + sample_count: threshold(sampleCount, 50, ">="), + langbot_overhead_p95_ms: threshold(metrics.langbot_overhead_ms.p95, overheadP95BudgetMs, "<="), + accounting_gap_max_ms: threshold(metrics.accounting_gap_max_ms, 0.001, "<="), + }; + const status = Object.values(thresholds).every((item) => item.pass) ? "pass" : "fail"; + const metricsPath = join(evidenceDir, "metrics.json"); + const thresholdsPath = join(evidenceDir, "thresholds.json"); + const resourceLogPath = join(evidenceDir, "resource-log.json"); + const automationResultPath = join(evidenceDir, "automation-result.json"); + const resultPath = join(evidenceDir, "result.json"); + + await writeFile(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`, "utf8"); + await writeFile(thresholdsPath, `${JSON.stringify(thresholds, null, 2)}\n`, "utf8"); + await writeFile(resourceLogPath, `${JSON.stringify({ memory, pid: process.pid }, null, 2)}\n`, "utf8"); + + const finishedAt = new Date(); + const result = { + source: "automation", + case_id: caseId, + run_id: runId, + status, + reason: status === "pass" + ? "Overhead accounting contract passed all thresholds." + : "Overhead accounting contract breached one or more thresholds.", + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: finishedAt.toISOString(), + finished_at_local: localIsoWithOffset(finishedAt), + duration_ms: finishedAt.getTime() - startedAt.getTime(), + metrics_summary: { + sample_count: metrics.sample_count, + langbot_overhead_p95_ms: metrics.langbot_overhead_ms.p95, + e2e_latency_p95_ms: metrics.e2e_latency_ms.p95, + external_latency_p95_ms: metrics.external_latency_ms.p95, + accounting_gap_max_ms: metrics.accounting_gap_max_ms, + }, + thresholds_summary: thresholds, + artifacts: { + metrics_json: metricsPath, + thresholds_json: thresholdsPath, + resource_log_json: resourceLogPath, + automation_result_json: automationResultPath, + result_json: resultPath, + }, + evidence_collected: ["metrics", "resource_log", "filesystem"], + }; + + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultPath, resultText, "utf8"); + await writeFile(resultPath, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + exit(status === "pass" ? 0 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/lib/fake-provider-timing.mjs b/skills/skills/langbot-testing/probes/lib/fake-provider-timing.mjs new file mode 100755 index 000000000..b383b2663 --- /dev/null +++ b/skills/skills/langbot-testing/probes/lib/fake-provider-timing.mjs @@ -0,0 +1,134 @@ +export function summarizeFakeProviderState(state) { + if (!state) return null; + const recentRequests = Array.isArray(state.recent_requests) ? state.recent_requests : []; + const chatRequests = recentRequests.filter((request) => String(request?.path || "").includes("/chat/completions")); + const successfulRequests = chatRequests.filter((request) => request?.status === "ok"); + const faultRequests = chatRequests.filter((request) => ( + request?.should_fail === true + || request?.status === "http_fault" + || (Number.isFinite(request?.http_status) && request.http_status >= 400) + )); + + return { + status: state.status || "unknown", + url: state.url || "", + request_count: Number.isFinite(state.request_count) ? state.request_count : recentRequests.length, + recent_request_count: recentRequests.length, + chat_request_count: chatRequests.length, + fault_count: faultRequests.length, + streamed_request_count: chatRequests.filter((request) => request?.stream === true).length, + duration_ms: stats(chatRequests.map((request) => numberOrNull(request?.duration_ms)).filter(Number.isFinite)), + successful_duration_ms: stats(successfulRequests.map((request) => numberOrNull(request?.duration_ms)).filter(Number.isFinite)), + first_chunk_ms: stats(successfulRequests.map((request) => numberOrNull(request?.first_chunk_ms)).filter(Number.isFinite)), + first_content_chunk_ms: stats(successfulRequests.map((request) => numberOrNull(request?.first_content_chunk_ms)).filter(Number.isFinite)), + content_chunk_count: stats(successfulRequests.map((request) => numberOrNull(request?.content_chunk_count)).filter(Number.isFinite)), + config: state.config || {}, + }; +} + +export function buildProviderTimingMetrics(samples, state) { + const recentRequests = Array.isArray(state?.recent_requests) ? state.recent_requests : []; + const byExpectedText = new Map(); + for (const request of recentRequests) { + const expected = String(request?.expected_text || ""); + if (!expected) continue; + if (!byExpectedText.has(expected)) byExpectedText.set(expected, []); + byExpectedText.get(expected).push(request); + } + + const segments = []; + const missingExpectedText = []; + for (const sample of samples) { + const expected = String(sample?.expected_text || ""); + if (!expected) continue; + const request = (byExpectedText.get(expected) || []).shift(); + if (!request) { + missingExpectedText.push(expected); + continue; + } + const segment = buildTimingSegment(sample, request); + if (segment) segments.push(segment); + } + + const values = (key) => segments.map((segment) => numberOrNull(segment[key])).filter(Number.isFinite); + return { + matched_request_count: segments.length, + missing_provider_match_count: missingExpectedText.length, + missing_expected_text: missingExpectedText.slice(0, 20), + send_to_provider_start_ms: stats(values("send_to_provider_start_ms")), + provider_duration_ms: stats(values("provider_duration_ms")), + provider_finish_to_ws_final_ms: stats(values("provider_finish_to_ws_final_ms")), + langbot_overhead_estimate_ms: stats(values("langbot_overhead_estimate_ms")), + e2e_minus_provider_ms: stats(values("e2e_minus_provider_ms")), + provider_first_content_to_ws_first_content_ms: stats(values("provider_first_content_to_ws_first_content_ms")), + segments, + }; +} + +function buildTimingSegment(sample, request) { + const sentEpochMs = numberOrNull(sample.sent_epoch_ms); + const finishedEpochMs = numberOrNull(sample.finished_epoch_ms); + const providerStartedEpochMs = numberOrNull(request.started_epoch_ms); + const providerFinishedEpochMs = numberOrNull(request.finished_epoch_ms); + const providerFirstContentEpochMs = numberOrNull(request.first_content_chunk_epoch_ms); + const wsFirstContentEpochMs = numberOrNull(sample.first_assistant_content_epoch_ms); + const responseDurationMs = numberOrNull(sample.response_duration_ms); + const providerDurationMs = numberOrNull(request.duration_ms); + + const sendToProviderStartMs = finiteDelta(providerStartedEpochMs, sentEpochMs); + const providerFinishToWsFinalMs = finiteDelta(finishedEpochMs, providerFinishedEpochMs); + const e2eMinusProviderMs = Number.isFinite(responseDurationMs) && Number.isFinite(providerDurationMs) + ? rounded(responseDurationMs - providerDurationMs) + : null; + const overheadEstimateMs = Number.isFinite(sendToProviderStartMs) && Number.isFinite(providerFinishToWsFinalMs) + ? rounded(sendToProviderStartMs + providerFinishToWsFinalMs) + : e2eMinusProviderMs; + + return { + sample_index: sample.index, + pipeline_label: sample.pipeline_label || "", + expected_text: sample.expected_text || "", + provider_request_id: request.id || "", + provider_request_number: request.request_number ?? null, + response_duration_ms: responseDurationMs, + provider_duration_ms: providerDurationMs, + send_to_provider_start_ms: sendToProviderStartMs, + provider_finish_to_ws_final_ms: providerFinishToWsFinalMs, + langbot_overhead_estimate_ms: overheadEstimateMs, + e2e_minus_provider_ms: e2eMinusProviderMs, + provider_first_content_to_ws_first_content_ms: finiteDelta(wsFirstContentEpochMs, providerFirstContentEpochMs), + provider_status: request.status || "", + provider_http_status: request.http_status ?? null, + }; +} + +function finiteDelta(left, right) { + return Number.isFinite(left) && Number.isFinite(right) ? rounded(left - right) : null; +} + +export function stats(values) { + if (values.length === 0) return { min: 0, p50: 0, p95: 0, p99: 0, max: 0 }; + return { + min: rounded(Math.min(...values)), + p50: percentile(values, 50), + p95: percentile(values, 95), + p99: percentile(values, 99), + max: rounded(Math.max(...values)), + }; +} + +export function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1); + return rounded(sorted[index]); +} + +export function rounded(value) { + return Number(value.toFixed(3)); +} + +function numberOrNull(value) { + const number = Number(value); + return Number.isFinite(number) ? number : null; +} diff --git a/skills/skills/langbot-testing/probes/pytest-probe.mjs b/skills/skills/langbot-testing/probes/pytest-probe.mjs new file mode 100755 index 000000000..db98361e4 --- /dev/null +++ b/skills/skills/langbot-testing/probes/pytest-probe.mjs @@ -0,0 +1,222 @@ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { basename, delimiter, join, resolve } from "node:path"; +import { env } from "node:process"; + +function loadEnvDefaults(root) { + for (const path of [join(root, "skills/.env"), join(root, "skills/.env.local")]) { + if (!existsSync(path)) continue; + for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const sep = line.indexOf("="); + if (sep === -1) continue; + const key = line.slice(0, sep).trim(); + if (env[key]) continue; + env[key] = line.slice(sep + 1).trim().replace(/^["']|["']$/g, ""); + } + } +} + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + const pad = (value) => String(value).padStart(2, "0"); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function resolveFromRoot(root, value) { + if (!value) return ""; + return resolve(root, value); +} + +function truncate(text, maxLength = 12000) { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength)}\n...[truncated ${text.length - maxLength} chars]`; +} + +function exitCode(status) { + if (status === "pass") return 0; + if (status === "blocked" || status === "env_issue") return 2; + return 1; +} + +async function runProcess(command, timeoutMs, childEnv) { + return await new Promise((resolveDone) => { + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + env: childEnv, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + setTimeout(() => { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + }, 5000).unref(); + }, timeoutMs); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null }); + }); + child.on("close", (status, signal) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error: null, timedOut, status, signal }); + }); + }); +} + +export async function runPytestProbe({ + caseId, + repoEnvKey, + defaultRepo, + pythonPathEnvKeys = [], + defaultPythonPaths = [], + testTargets, + description, + timeoutMs, +}) { + const root = resolve(env.LBS_ROOT || process.cwd()); + loadEnvDefaults(root); + const resolvedTimeoutMs = Number(timeoutMs || env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "180000"); + + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + const uvCacheDir = env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"); + await mkdir(uvCacheDir, { recursive: true }); + + const startedAt = new Date(); + const repoPath = resolveFromRoot(root, env[repoEnvKey] || defaultRepo); + const pythonPaths = [ + ...pythonPathEnvKeys.map((key) => env[key]).filter(Boolean), + ...defaultPythonPaths, + ].map((value) => resolveFromRoot(root, value)); + const automationResultJson = join(evidenceDir, "automation-result.json"); + const stdoutLog = join(evidenceDir, "pytest-stdout.log"); + const stderrLog = join(evidenceDir, "pytest-stderr.log"); + const resultJson = join(evidenceDir, "result.json"); + const command = { + executable: "rtk", + args: ["uv", "run", "pytest", "-q", ...testTargets], + cwd: repoPath, + }; + const result = { + source: "automation", + probe: "pytest", + case_id: caseId, + run_id: runId, + description, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + status: "fail", + reason: "", + repo_env_key: repoEnvKey, + repo_path: repoPath, + python_paths: pythonPaths, + test_targets: testTargets, + command, + timeout_ms: resolvedTimeoutMs, + uv_cache_dir: uvCacheDir, + exit_status: null, + signal: null, + evidence: { + pytest_stdout_log: stdoutLog, + pytest_stderr_log: stderrLog, + automation_result_json: automationResultJson, + result_json: resultJson, + }, + evidence_collected: ["filesystem"], + }; + + await writeFile(stdoutLog, "", "utf8"); + await writeFile(stderrLog, "", "utf8"); + + try { + if (!existsSync(repoPath)) { + result.status = "env_issue"; + result.reason = `${repoEnvKey || "repo"} did not resolve to an existing directory: ${repoPath}`; + } else { + const missingTargets = testTargets.filter((target) => !existsSync(join(repoPath, target.split("::")[0]))); + if (missingTargets.length > 0) { + result.status = "env_issue"; + result.reason = `pytest target file(s) not found in ${basename(repoPath)}: ${missingTargets.join(", ")}`; + } else { + const childEnv = { ...process.env, UV_CACHE_DIR: uvCacheDir }; + if (pythonPaths.length > 0) { + childEnv.PYTHONPATH = [pythonPaths.join(delimiter), childEnv.PYTHONPATH].filter(Boolean).join(delimiter); + } + const proc = await runProcess(command, resolvedTimeoutMs, childEnv); + result.exit_status = proc.status; + result.signal = proc.signal; + await writeFile(stdoutLog, truncate(proc.stdout), "utf8"); + await writeFile(stderrLog, truncate(proc.stderr), "utf8"); + + if (proc.error) { + result.status = "env_issue"; + result.reason = `Failed to start pytest command '${command.executable}': ${proc.error.message}`; + } else if (proc.timedOut) { + result.status = "fail"; + result.reason = `pytest timed out after ${resolvedTimeoutMs}ms. See ${stdoutLog} and ${stderrLog}.`; + } else if (proc.status === 0) { + result.status = "pass"; + result.reason = `pytest passed for ${testTargets.join(", ")}.`; + } else if (/command not found|no such file or directory|executable file not found/i.test(`${proc.stdout}\n${proc.stderr}`)) { + result.status = "env_issue"; + result.reason = `pytest command could not run in ${repoPath}. See ${stdoutLog} and ${stderrLog}.`; + } else { + result.status = "fail"; + result.reason = `pytest exited with status ${proc.status}. See ${stdoutLog} and ${stderrLog}.`; + } + } + } + } catch (error) { + result.status = "fail"; + result.reason = error instanceof Error ? error.message : String(error); + } finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultJson, resultText, "utf8"); + await writeFile(resultJson, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } + + process.exit(exitCode(result.status)); +} diff --git a/skills/skills/langbot-testing/references/agent-runner-qa-workflow.md b/skills/skills/langbot-testing/references/agent-runner-qa-workflow.md new file mode 100644 index 000000000..76e62fa44 --- /dev/null +++ b/skills/skills/langbot-testing/references/agent-runner-qa-workflow.md @@ -0,0 +1,112 @@ +# AgentRunner QA Workflow + +Use this workflow when an agent finishes AgentRunner-related code and enters a +test phase. + +## Order + +1. Inspect changed repositories with `rtk git status --short` and + `rtk git diff --stat`. + Also run `rtk git diff --name-only` and, when untracked files are present, + `rtk git ls-files --others --exclude-standard` so new probes, fixtures, and + cases are not missed. +2. Choose the smallest relevant checks: + - Fast contract probes first. + - Targeted repo tests second. + - Browser cases only after contract probes are green or clearly classified. +3. Start from saved assets: + - `rtk bin/lbs test recommend` + - `rtk bin/lbs suite plan agent-runner-release-gate` + - `rtk bin/lbs case list --tag agent-runner` + - `rtk bin/lbs trouble search agent-runner` +4. Run probes before browser cases: + - `rtk bin/lbs test run agent-runner-fixture-contract --dry-run` + - `rtk bin/lbs test run agent-runner-live-install --dry-run` when a local LangBot + backend is available and installing the QA fixture is acceptable. + - `rtk bin/lbs test run agent-runner-qa-debug-chat --dry-run` when WebUI live + execution needs deterministic coverage without a model provider. This + case runs its setup automation first: install the QA AgentRunner fixture, + create/update the QA pipeline, write the case-specific pipeline env, then + execute Debug Chat. + - `rtk bin/lbs test run agent-runner-ledger-invariants --dry-run` + - `rtk bin/lbs test run agent-runner-ledger-contention --dry-run` + - `rtk bin/lbs test run agent-runner-runtime-chaos --dry-run` + - `rtk bin/lbs test run agent-runner-ledger-concurrency --dry-run` when async DB + readiness is known good. +5. Run browser release paths only after environment preflight: + - `rtk bin/lbs test run agent-runner-release-preflight --dry-run` + - `rtk bin/lbs suite start agent-runner-release-gate --run-id ` + +Remove `--dry-run` only after readiness and `manual_check` preconditions are +confirmed for the current test instance. + +## Diff Triage + +Use changed paths to choose checks. Start with the most specific row that +matches the diff, then add adjacent rows only when shared contracts changed. +For the common case, run `rtk bin/lbs test recommend` first and use this table +only to review or adjust the generated list. + +| Changed Path or Area | First Checks | Add Browser Case When | +| --- | --- | --- | +| `LangBot/src/langbot/pkg/agent/runner/*`, `tests/unit_tests/agent/test_result_normalizer.py`, protocol/result/context/resource builders | `rtk bin/lbs test run agent-runner-fixture-contract --dry-run`; `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted LangBot unit tests for touched files | Result shape, user-visible runner output, or Debug Chat delivery changed: add `pipeline-debug-chat` or `local-agent-basic-debug-chat`. | +| `LangBot/src/langbot/pkg/entity/persistence/agent_run.py`, `run_journal.py`, run ledger store/API/auth tests, claim/lease/status code | `rtk bin/lbs test run agent-runner-ledger-invariants --dry-run`; `rtk bin/lbs test run agent-runner-ledger-stress --dry-run`; `rtk bin/lbs test run agent-runner-ledger-contention --dry-run`; `rtk bin/lbs test run agent-runner-async-db-readiness --dry-run` before `rtk bin/lbs test run agent-runner-ledger-concurrency --dry-run` | Debug Chat run lifecycle, resume, or visible completion changed: add `local-agent-basic-debug-chat`. | +| `langbot-plugin-sdk/src/langbot_plugin/api/entities/builtin/agent_runner/*`, `api/proxies/agent_run_api.py`, runtime pull handlers, plugin manager/runtime IO | `rtk bin/lbs test run agent-runner-runtime-chaos --dry-run`; `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted SDK pytest | Runtime delivery or tool-call surface changed: add `agent-runner-release-preflight`, then `local-agent-basic-debug-chat`. | +| `langbot-agent-runner/*/components/agent_runner/*`, external runner daemon/client code, ACP/Codex/Claude runner command wrappers | Repo-local targeted tests; `rtk bin/lbs test run agent-runner-runtime-chaos --dry-run`; `rtk bin/lbs test run agent-runner-release-preflight --dry-run` | ACP or external coding runner behavior changed: add `acp-agent-runner-debug-chat`. | +| Prompt preprocessing, effective prompt, pipeline AI config, runner binding/default runner migration | `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted LangBot pipeline/agent tests | The runner reads host-provided prompt or saved runner config: add `local-agent-effective-prompt-debug-chat`. | +| Context window, transcript, history/event state, compaction, checkpoint/steering | `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted LangBot agent state/context tests | Multi-turn memory, compaction, or steering behavior changed: add `local-agent-context-compaction-debug-chat` and, for steering-specific changes, `local-agent-steering-debug-chat`. | +| Plugin tool authorization, host tool listing, MCP tool bridge, function-call conversion | `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted plugin/MCP/tool tests | Tool execution is user-visible: add `local-agent-plugin-tool-call-debug-chat`; for MCP-specific changes add `mcp-stdio-register` then `mcp-stdio-tool-call`. | +| RAG context injection, knowledge base retrieval, resource packaging | Targeted LangBot RAG/resource tests; `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run` when runner input shape changed | Runner answer should include retrieved context: add `langrag-kb-retrieve` and `local-agent-rag-debug-chat`. | +| Streaming/non-streaming adapter, provider message conversion, image or multimodal payloads | `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted provider/pipeline tests | User-visible transport changed: add `local-agent-basic-debug-chat`, `local-agent-nonstreaming-debug-chat`, or `local-agent-multimodal-debug-chat` matching the diff. | +| Only `langbot-skills` cases, probes, fixtures, references, schemas, or CLI planning code | `rtk bin/lbs validate`; relevant `rtk bin/lbs test plan ` or `rtk bin/lbs test run --dry-run` if supported | Do not run browser cases unless the edited asset itself changes the browser path being validated. | + +If a diff crosses more than two rows or touches protocol, ledger, SDK runtime, +and browser-visible runner behavior together, stop trying to hand-pick a tiny +set and use `agent-runner-release-gate`. + +## Recommended Minimal Sets + +- Protocol or normalization only: `agent-runner-fixture-contract`, + `agent-runner-behavior-matrix`, plus targeted repo unit tests. +- Ledger persistence only: `agent-runner-ledger-invariants`, + `agent-runner-ledger-stress`, `agent-runner-ledger-contention`, and + `agent-runner-async-db-readiness`; run `agent-runner-ledger-concurrency` only + when async DB readiness passes. +- SDK runtime only: `agent-runner-runtime-chaos` plus targeted SDK pytest. +- Local-agent user path: `agent-runner-release-preflight` then + `local-agent-basic-debug-chat`; add the specific RAG/tool/MCP/context/ + multimodal case only when the diff touches that contract. +- External ACP runner path: `agent-runner-release-preflight` then + `acp-agent-runner-debug-chat`. + +## Asset Rules + +- If a stable product path is missing, add one `cases/*.yaml` file. +- If a non-UI invariant or stress check is missing, add one `mode: probe` case + and one script under `probes/`. +- If the failure repeats, add one `troubleshooting/*.yaml` entry. +- Keep probe scripts deterministic. Prefer stdlib, existing repo tests, and + local fixtures over real provider calls. +- Do not mark a browser case passed from API/curl/probe evidence alone. + +## Result Classification + +- `pass`: declared checks and required evidence passed. +- `fail`: product or contract behavior is wrong. +- `env_issue`: the target could not run because a runtime dependency failed, + such as `aiosqlite.connect()` hanging before Host ledger tests start. +- `blocked`: required pipeline, plugin, credential, or fixture is missing. +- `flaky`: rerun needed because evidence shows transient instability. + +## Maintenance Gate + +After adding or editing QA assets: + +```bash +rtk bin/lbs validate +rtk bin/lbs index +rtk npm test +``` + +Commit only reusable assets and code. Do not commit `reports/evidence/*` +run output. diff --git a/skills/skills/langbot-testing/references/agent-runner-release-gate.md b/skills/skills/langbot-testing/references/agent-runner-release-gate.md new file mode 100644 index 000000000..89a75fcfa --- /dev/null +++ b/skills/skills/langbot-testing/references/agent-runner-release-gate.md @@ -0,0 +1,172 @@ +# Agent Runner Release Gate + +Use this reference when judging whether runner externalization is release-ready. The goal is not to enumerate every possible prompt. The gate covers product abilities and trust boundaries with deterministic normal-path cases, then leaves rare negative branches to unit and contract tests. + +## Coverage Strategy + +Treat the release gate as five layers: + +| Layer | Purpose | Primary Assets | +| --- | --- | --- | +| Contract gate | Protocol, SDK, auth, stores, and plugin handler behavior without a browser. | `agent-runner-fixture-contract`, `agent-runner-behavior-matrix`, `agent-runner-ledger-invariants`, `agent-runner-ledger-stress`, `agent-runner-ledger-contention`, `agent-runner-runtime-chaos`, `agent-runner-ledger-concurrency`, plus unit and contract tests in touched repos. | +| Environment preflight | Prove the selected live instance is configured for the full gate before expensive browser cases start. | `agent-runner-live-install`, `agent-runner-qa-debug-chat`, `agent-runner-release-preflight`. | +| Fixture gate | Prove deterministic plugin, RAG, multimodal, and MCP fixtures are installed and registered. | `plugin-e2e-smoke`, `langrag-kb-retrieve`, `mcp-stdio-register`. | +| Local-agent capability gate | Prove normal user-facing local-agent paths through WebUI Debug Chat. | `local-agent-gate` cases. | +| External harness gate | Prove ACP can execute an external coding agent through the WebUI Debug Chat path. | `acp-agent-runner-debug-chat`. | + +Run the full release gate with: + +```bash +rtk bin/lbs suite run agent-runner-release-gate --dry-run --json +rtk bin/lbs suite plan agent-runner-release-gate +rtk bin/lbs suite start agent-runner-release-gate --run-id agent-runner-release- +``` + +Confirm readiness and `manual_check` preconditions before removing `--dry-run` +or running the generated per-case commands. Then finish with: + +```bash +rtk bin/lbs suite report agent-runner-release-gate --evidence-dir reports/evidence/agent-runner-release- +``` + +For a quick early blocker check, run: + +```bash +rtk bin/lbs test run agent-runner-release-preflight --dry-run +``` + +For the code-level AgentRunner probes, run: + +```bash +rtk bin/lbs test run agent-runner-behavior-matrix --dry-run +rtk bin/lbs test run agent-runner-fixture-contract --dry-run +rtk bin/lbs test run agent-runner-ledger-invariants --dry-run +rtk bin/lbs test run agent-runner-ledger-stress --dry-run +rtk bin/lbs test run agent-runner-ledger-contention --dry-run +rtk bin/lbs test run agent-runner-async-db-readiness --dry-run +rtk bin/lbs test run agent-runner-ledger-concurrency --dry-run +rtk bin/lbs test run agent-runner-runtime-chaos --dry-run +rtk bin/lbs test run agent-runner-live-install --dry-run +rtk bin/lbs test run agent-runner-qa-debug-chat --dry-run +``` + +`agent-runner-behavior-matrix` executes the deterministic behavior fixture at +`fixtures/agent-runner/qa-runner-behaviors.json` through Host result +normalization. It covers normal completed output, streaming output, empty output, +malformed payloads, and controlled failure output without a model provider. + +`agent-runner-fixture-contract` imports the source fixture at +`fixtures/plugins/qa-agent-runner` and executes normal, streaming, and +controlled-failure paths with SDK protocol entities. It proves the deterministic +QA runner source is usable before a live installation/browser case uses it. +`bin/lbs fixture check` also verifies the matching +`fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg` package is +present and is a zip package. + +`agent-runner-live-install` uploads that package to a local LangBot backend and +checks that `qa/agent-runner` is installed and +`plugin:qa/agent-runner/default` appears in pipeline runner metadata. It is an +API integration gate, not a Debug Chat execution proof. + +`agent-runner-qa-debug-chat` is the deterministic live execution proof. It uses +a pipeline created by `scripts/e2e/ensure-qa-agent-runner-pipeline.mjs` and +expects Debug Chat to return `QA_AGENT_RUNNER_OK:` through +`plugin:qa/agent-runner/default`. + +`agent-runner-ledger-invariants` is the fast Host ledger probe. It uses +synchronous SQLite and checks run status sets, terminal status validation, +ledger table/index DDL, and a minimal insert/read path without a browser or +`aiosqlite`. + +`agent-runner-ledger-stress` is a fast deterministic stress baseline. It uses +synchronous SQLite to create 100 queued runs and simulates five runtimes claiming +each run exactly once. It does not replace async/PostgreSQL concurrency tests, +but catches schema and ordering regressions quickly. + +`agent-runner-ledger-contention` is a local write-contention probe. It uses a +file-backed SQLite database, 120 queued runs, and eight worker threads to verify +that each run is claimed exactly once under concurrent writers. It still does +not replace async/PostgreSQL concurrency tests. + +`agent-runner-async-db-readiness` checks whether direct `aiosqlite` startup is +healthy before running async Host ledger pytest probes. + +`agent-runner-ledger-concurrency` is the async Host pytest probe. It exercises +selected run ledger store/API auth tests from `LANGBOT_REPO` or `../LangBot`. +If it times out before any test result and a direct `aiosqlite.connect()` script +also hangs, classify the run with troubleshooting id +`aiosqlite-connect-hangs` instead of treating it as a browser E2E failure. + +`agent-runner-runtime-chaos` runs SDK AgentRunner runtime and pull API handler +tests from `LANGBOT_PLUGIN_SDK_REPO` or `../langbot-plugin-sdk`. +Each probe writes `automation-result.json` and probe logs under +`LBS_EVIDENCE_DIR`. + +## Normal-Path Matrix + +| Product Path | Case Coverage | What It Proves | +| --- | --- | --- | +| Authenticated WebUI session | `webui-login-state`, `agent-runner-release-preflight` | The browser profile can operate the same backend that later cases use. | +| Generic Pipeline Debug Chat | `pipeline-debug-chat` | The WebUI Debug Chat path itself works before runner-specific failures are diagnosed. | +| Deterministic QA runner install | `agent-runner-live-install` | A local `.lbpkg` AgentRunner package can install and register a runner. | +| Deterministic QA runner Debug Chat | `agent-runner-qa-debug-chat` | The installed QA runner executes through WebUI Debug Chat without a model provider. | +| Required runner plugins | `agent-runner-release-preflight` | `langbot/local-agent` and `langbot/acp-agent-runner` are visible to the host. | +| Required QA plugin tool | `plugin-e2e-smoke`, `agent-runner-release-preflight` | The deterministic `qa_plugin_echo` tool is exposed before tool-loop cases start. | +| Knowledge base fixture | `langrag-kb-retrieve`, `local-agent-rag-debug-chat` | LangRAG data is queryable and the runner inserts retrieved context. | +| Effective prompt bridge | `local-agent-effective-prompt-debug-chat` | Host prompt preprocessing reaches the runner. | +| History and compaction | `local-agent-context-compaction-debug-chat` | Runner-owned history budgeting keeps recoverable older context. | +| Streaming LLM | `local-agent-basic-debug-chat` | The default streaming path returns a visible answer. | +| Non-streaming LLM | `local-agent-nonstreaming-debug-chat` | The non-streaming adapter path returns a visible answer. | +| Plugin tool loop | `local-agent-plugin-tool-call-debug-chat` | Function-call capable models can call host plugin tools through authorization. | +| MCP registration | `mcp-stdio-register` | The deterministic stdio MCP server is registered and exposes `qa_mcp_echo`. | +| MCP tool loop | `mcp-stdio-tool-call` | Local-agent can call the registered MCP tool through the same tool loop. | +| Multimodal input | `local-agent-multimodal-debug-chat` | Image upload and structured input reach the runner. | +| Multimodal plus RAG | `local-agent-rag-multimodal-debug-chat` | RAG still works when structured image input is present. | +| ACP external harness execution | `acp-agent-runner-debug-chat` | ACP executes the configured coding agent and returns visible Debug Chat output. | + +## Status Taxonomy + +Use the same final result categories for every case: + +- `pass`: the visible UI behavior and required evidence match the case checks. +- `fail`: the configured product path is reachable, but LangBot or the runner behaves incorrectly. +- `blocked`: the test instance is not configured for this gate, for example missing pipeline, wrong runner id, missing required plugin, or unreachable ACP agent runtime. +- `env_issue`: the runtime dependency is unhealthy, for example backend down, plugin runtime down, Box down, provider route unavailable, invalid API key, or missing model ability in the selected route. +- `flaky`: the path can pass but the run hit a transient network, marketplace, upstream provider, or timing problem that needs a rerun and evidence. + +Do not count `blocked` or `env_issue` as product pass. They are useful release signals because they prevent false confidence. + +## PR Gate + +Before a browser release run, also keep the code-level gate green in the repos touched by the branch: + +```bash +# langbot-agent-runner +rtk uv run pytest -q + +# langbot-plugin-sdk +rtk uv run pytest -q + +# langbot-skills saved AgentRunner probes +rtk bin/lbs test run agent-runner-behavior-matrix --dry-run +rtk bin/lbs test run agent-runner-ledger-invariants --dry-run +rtk bin/lbs test run agent-runner-ledger-stress --dry-run +rtk bin/lbs test run agent-runner-async-db-readiness --dry-run +rtk bin/lbs test run agent-runner-ledger-concurrency --dry-run +rtk bin/lbs test run agent-runner-runtime-chaos --dry-run + +# LangBot, target the touched package first, then broaden if shared behavior changed +rtk uv run pytest -q +``` + +These tests cover field-level protocol conformance, SDK proxy behavior, auth failures, and negative branches that should not depend on a live provider. The browser gate then proves the normal user paths still compose correctly. + +## Release Decision + +For runner externalization, a release candidate is acceptable only when: + +- PR contract tests are green in every touched repo. +- `agent-runner-release-preflight` has no blockers and no environment issues. +- Every case in `agent-runner-release-gate` has a final `result.json`. +- No `pass` result is missing required evidence. +- Any skipped case is explicitly classified as `blocked` or `env_issue` with a concrete owner and follow-up. diff --git a/skills/skills/langbot-testing/references/dify-agent-runner.md b/skills/skills/langbot-testing/references/dify-agent-runner.md new file mode 100644 index 000000000..e57695950 --- /dev/null +++ b/skills/skills/langbot-testing/references/dify-agent-runner.md @@ -0,0 +1,48 @@ +# Dify AgentRunner + +Use this reference when validating `langbot/dify-agent` through LangBot WebUI. + +## Prepare Dify + +- Use a Dify Service API key from the target Dify app. Do not print the key in reports. +- For Dify Agent Chat apps, configure LangBot `app-type` as `agent`. +- Dify Agent Chat Service API may reject direct `blocking` mode with `Agent Chat App does not support blocking mode`; use streaming for direct diagnostics. + +## LangBot Configuration + +1. Open `LANGBOT_FRONTEND_URL`. +2. Navigate to `Pipelines` and open the target pipeline. +3. Open `Configuration > AI`. +4. Select runner `Dify`. +5. Configure: + - `Base URL`: usually `https://api.dify.ai/v1` + - `App Type`: `Agent` for Dify Agent Chat apps + - `API Key`: Dify Service API key + - `Base Prompt`: short neutral prompt unless the case needs a specific prompt + - `Timeout`: at least `60` when testing through proxies +6. Save before using Debug Chat. + +## Debug Chat Check + +Send a prompt with a unique sentinel: + +```text +Reply exactly with LANGBOT_DIFY_ and nothing else. +``` + +Pass only when: + +- UI shows a `Bot` message containing the sentinel. +- WebSocket history or DOM inspection confirms the sentinel is in an assistant/bot message, not only in the user message. +- Backend logs show the request completed, for example `HTTP Request: POST https://api.dify.ai/v1/chat-messages "HTTP/1.1 200 OK"` and `Conversation(0) Streaming completed`. + +## Diagnostics + +- `GET /api/v1/pipelines/{uuid}` can confirm the saved runner id is `plugin:langbot/dify-agent/default` and runner config contains `app-type`, `base-url`, and `api-key`. +- Direct Dify streaming API calls are useful only to distinguish invalid Dify credentials from LangBot runner issues. +- If Debug Chat returns `Agent runner execution failed`, inspect backend logs before changing UI settings. + +## Known Failure Signatures + +- `AttributeError: 'ActorContext' object has no attribute 'type'`: runner code is reading old actor fields; see troubleshooting `agent-runner-actor-context-fields`. +- Multiple runner options display as `默认`: component labels are ambiguous; see troubleshooting `ambiguous-runner-default-label`. diff --git a/skills/skills/langbot-testing/references/langrag-knowledge-base.md b/skills/skills/langbot-testing/references/langrag-knowledge-base.md new file mode 100644 index 000000000..00e0fb816 --- /dev/null +++ b/skills/skills/langbot-testing/references/langrag-knowledge-base.md @@ -0,0 +1,72 @@ +# LangRAG Knowledge Base + +Use this reference when validating LangRAG creation, document ingestion, retrieval, and local-agent RAG behavior. + +## Setup + +1. Install `langbot-team/LangRAG` from Marketplace if `/api/v1/knowledge/engines` has no LangRAG engine. +2. Confirm `LANGBOT_BACKEND_URL/api/v1/knowledge/engines` contains plugin id `langbot-team/LangRAG`. +3. Prefer local Chroma embedding for offline/free tests: + - Provider requester: `chroma-embedding` + - Embedding model name: `chroma-all-MiniLM-L6-v2` + +Important: a Chroma embedding entry must exist under `embedding_models`. A model accidentally created as an LLM model will appear in the wrong model selector and will not satisfy LangRAG's embedding-model field. + +## Parser Golden Case + +Use `cases/langrag-parser-golden-e2e.yaml` when validating the LangRAG + GeneralParsers integration on the current master worktree. + +Fixture: + +```text +fixtures/rag/parser-golden.html +``` + +Golden intent: + +- Start LangBot from `LANGBOT_REPO`, which should point at the master worktree for this run. +- Build and install/update local `LANGBOT_RAG_PLUGIN_REPO` and `LANGBOT_PARSER_PLUGIN_REPO`. +- Upload the HTML fixture and select GeneralParsers when the parser chooser is shown. +- Confirm retrieval returns `aurora-parser-rag-9137`, `GeneralParsers`, `LangRAG`, and the Markdown table header `| Parser field | Golden value |`. +- Confirm logs show LangRAG used external pre-parsed content instead of the internal fallback parser. + +Local install pitfall: + +- If GeneralParsers fails while installing `PyMuPDF>=1.24.0`, read `troubleshooting/plugin-dependency-install-offline.yaml`. +- The golden case can continue after the active LangBot master venv can `import fitz` and `python -m pip install --dry-run 'PyMuPDF>=1.24.0'` reports the requirement is satisfied. + +## Browser Flow + +1. Open `LANGBOT_FRONTEND_URL`. +2. Navigate to `Knowledge`. +3. Create a knowledge base. +4. Select engine `LangRAG`. +5. Select embedding model `chroma-all-MiniLM-L6-v2` or another known working embedding model. +6. Keep the index type as `Chunk` for smoke/regression tests. +7. Upload a small sentinel document. +8. Wait until the document row status is `Completed`. +9. Open `Retrieve Test` and query for the sentinel. + +Recommended fixture: + +```text +fixtures/rag/sentinel-doc.txt +``` + +## Pass Criteria + +- The created knowledge base appears in the sidebar. +- The uploaded document reaches `Completed`. +- Retrieve Test returns the uploaded document with the sentinel text. +- Browser console has no unexpected errors. + +## Local-Agent RAG Check + +After retrieval passes: + +1. Open the target pipeline. +2. In `Configuration > AI`, add the knowledge base to `Knowledge Bases`. +3. Save. +4. Open `Debug Chat`. +5. Ask for the sentinel. +6. Confirm the bot response contains the exact sentinel. diff --git a/skills/skills/langbot-testing/references/local-agent-runner-coverage.md b/skills/skills/langbot-testing/references/local-agent-runner-coverage.md new file mode 100644 index 000000000..04495f8d5 --- /dev/null +++ b/skills/skills/langbot-testing/references/local-agent-runner-coverage.md @@ -0,0 +1,74 @@ +# Local Agent Runner Coverage + +Use this matrix when judging whether the external `langbot/local-agent` plugin still behaves like the old built-in local-agent runner. + +The QA target is end-to-end behavior. UI cases prove the host, SDK, plugin runtime, and WebUI work together. Unit or component tests are still needed for negative branches that are hard to trigger reliably through a live provider. + +## Code Path Basis + +- `LangBot/src/langbot/pkg/agent/runner/context_builder.py` builds the Protocol v1 context from the event envelope: `ctx.input.text`, `ctx.input.contents`, attachments, state, resources, and runtime metadata. +- `LangBot/src/langbot/pkg/agent/runner/pipeline_adapter.py` adapts Pipeline-only fields into `ctx.adapter.extra.prompt`, `ctx.adapter.extra.params`, and optional `ctx.bootstrap.messages`. +- `LangBot/src/langbot/pkg/agent/runner/resource_builder.py` authorizes models, fallback models, rerank models, tools, and knowledge bases for the current run. +- `LangBot/src/langbot/pkg/plugin/handler.py` validates run-scoped model/tool/rerank access and calls the host model provider or tool manager with the current query. +- `langbot-local-agent/components/agent_runner/default.py` selects streaming or non-streaming execution, retrieves RAG context, builds messages, invokes models with fallback, and runs tool loops. +- `langbot-local-agent/pkg/messages.py` prefers the host effective prompt from `ctx.adapter.extra.prompt`, uses `ctx.bootstrap.messages` only as a small bootstrap window, and preserves structured/multimodal input while inserting RAG context. + +TODO: Treat `ctx.adapter.extra.prompt` as a temporary Pipeline bridge for old +local-agent behavior parity. It is not the final answer for how user plugins or +host hooks should influence agent behavior after Pipeline is replaced. + +## Minimum UI Gate + +These browser cases are the minimum gate for a local-agent migration check: + +| Case | Path Covered | Expected Behavior | +| --- | --- | --- | +| `local-agent-basic-debug-chat` | Streaming LLM invocation with effective host context | Bot returns deterministic `OK`; backend logs streaming completion. | +| `local-agent-effective-prompt-debug-chat` | PromptPreProcessing and host effective prompt handoff through `ctx.adapter.extra.prompt` | Bot returns `PROMPT_PREPROCESS_OK` from the fixture prompt probe. | +| `local-agent-context-compaction-debug-chat` | Runner-owned context budgeting and old-history compaction | Automation temporarily shrinks the runner context window, sends multi-turn Debug Chat history, and the bot still recovers the older sentinel. | +| `local-agent-rag-debug-chat` | Knowledge-base authorization, retrieval, and RAG prompt insertion | Bot returns the KB sentinel, not a generic answer. | +| `mcp-stdio-tool-call` | MCP stdio discovery, tool detail, model function calling, and tool execution | Bot returns `qa_mcp_echo:` and backend logs the MCP tool call. | +| `local-agent-plugin-tool-call-debug-chat` | Plugin tool discovery, tool detail, model function calling, and tool execution | Bot returns `qa-plugin-smoke:` and backend logs the plugin tool call. | +| `local-agent-steering-debug-chat` | Host steering claim, runner pull at turn boundary, and follow-up injection during an active tool loop | Two user messages produce one assistant response containing the steering sentinel. | +| `local-agent-multimodal-debug-chat` | Image upload, structured input contents, and multimodal runner consumption | UI shows uploaded image and bot returns `IMAGE_OK`; backend receives an image input. | +| `local-agent-rag-multimodal-debug-chat` | RAG insertion while structured image input is present | UI shows uploaded image, bot returns the KB sentinel, and backend logs the same request with `[Image]`. | +| `local-agent-nonstreaming-debug-chat` | Host non-streaming adapter path and runner non-streaming invocation | Bot returns `NONSTREAM_OK`; backend completes without the streaming-completed path. | + +## Full Coverage Matrix + +| Area | How To Cover | Pass Signal | +| --- | --- | --- | +| Effective prompt | Use the `qa-plugin-smoke` prompt probe and send `qa-effective-prompt`. | The answer follows `query.prompt.messages` and returns `PROMPT_PREPROCESS_OK`; plugin-local fallback config prompt is not used when host prompt exists. | +| Current text input | Send a deterministic text-only Debug Chat prompt. | `ctx.input.text` becomes the user text and the bot answers the text request. | +| Structured input contents | Upload an image with text in Debug Chat. | User message shows the image; backend log or request payload contains image content; model can acknowledge it. | +| Multimodal plus RAG | Run `local-agent-rag-multimodal-debug-chat`. | RAG sentinel is still retrievable and the image is not dropped from the user message; exact image-preservation inside the model message is covered by unit tests. | +| History and context compaction | Run `local-agent-context-compaction-debug-chat` with a small temporary `context-window-tokens` budget. | The runner compacts older history into `` and the final answer still recovers the older sentinel from the compacted context. | +| Streaming model invocation | Enable Debug Chat streaming and ask for `OK`. | UI receives incremental bot output and backend logs streaming completion. | +| Non-streaming model invocation | Disable Debug Chat streaming or use a non-streaming adapter path. | UI receives a final bot message and backend logs a normal response completion. | +| Model fallback before first chunk | Configure a failing primary and working fallback, preferably with a controlled test provider. | First model failure does not fail the run; fallback model produces the final answer. | +| Failure after streaming commit | Use a controlled provider that emits one chunk and then fails. | Runner reports a terminal run failure and does not fallback after partial output. | +| No authorized model | Clear model config or configure a model not in run resources. | Runner returns `runner.no_model` instead of calling an unauthorized model. | +| MCP tool call | Use `qa-local-stdio` and `qa_mcp_echo`. | Bot returns the exact `qa_mcp_echo:` result; `/api/v1/tools` contains `qa_mcp_echo`. | +| Plugin tool call | Install a fixture plugin exposing a deterministic tool and bind it to the pipeline. | Runner lists the plugin tool and can call it through the same tool loop as MCP tools. | +| Run steering | Use `local-agent-steering-debug-chat` with the fixture `qa_plugin_sleep` tool. | A follow-up sent while the sleep tool keeps the run active is claimed into the same run: two user messages, one assistant response, sentinel included. | +| Tool errors | Make the model request an unauthorized tool or invalid arguments in a controlled unit/component test. | Tool result contains an error message and the run does not bypass authorization. | +| Tool iteration limit | Use a controlled model/tool fixture that repeatedly requests more tool calls. | Runner stops with `runner.tool_loop_limit` at the configured limit. | +| Knowledge retrieval | Bind a KB containing a unique sentinel. | Bot returns the sentinel and backend logs LangRAG retrieval. | +| Legacy `knowledge-base` config | Load a pipeline config using the old single-KB field. | Runner still retrieves from the KB. | +| Rerank | Configure `rerank-model` and `rerank-top-k` with a working rerank provider. | Retrieval order follows rerank output; unauthorized or failing rerank falls back to original retrieval order. | +| Remove-think | Enable output `remove-think` on a model that emits think tags. | Final visible output omits think content on both streaming and non-streaming paths. | +| Model extra args | Configure provider/model extra args and run Debug Chat. | Host merges persisted model extra args before provider invocation. | +| Query-aware tools | Call a tool that needs the current Query/session context. | Tool receives the active query and behaves the same as it did under the built-in runner. | +| Params filtering | Add public and secret-like variables before the run. | Public params are visible to the runner; `_internal`, token, key, password, and credential fields are filtered. | +| Actor/session context | Run through Debug Chat and at least one platform adapter path. | `conversation`, `actor`, `subject`, and state scopes contain stable IDs for the current launcher and sender. | + +## Reporting Rules + +When reporting a local-agent QA result, separate these categories: + +- `Passed by UI`: path was verified through browser-visible behavior and backend/network evidence. +- `Covered by unit/component tests`: path is deterministic in tests but not practical as a live UI case. +- `Not covered`: path still needs a fixture or provider setup. +- `Environment issue`: provider channel, proxy, OAuth, or external marketplace/network problem outside the runner path. + +Do not mark the whole runner healthy based only on a single text Debug Chat response. diff --git a/skills/skills/langbot-testing/references/local-agent-runner.md b/skills/skills/langbot-testing/references/local-agent-runner.md new file mode 100644 index 000000000..63a2b7e94 --- /dev/null +++ b/skills/skills/langbot-testing/references/local-agent-runner.md @@ -0,0 +1,75 @@ +# Local Agent Runner + +Use this reference when validating the pluginized `langbot/local-agent` runner through the WebUI. + +The goal is behavior parity with the old built-in local-agent runner. The code does not need to be identical, but the visible behavior should match: effective prompt, current input, history, model selection and fallback, tool calling, knowledge retrieval, multimodal input, streaming and non-streaming output all have to reach the runner through the host and SDK. + +For path-by-path coverage, read [Local Agent Runner Coverage](local-agent-runner-coverage.md). + +## Main Surface + +- Open `LANGBOT_FRONTEND_URL`. +- Navigate to `Pipelines`. +- Open the target pipeline. +- In `Configuration > AI`, select runner `Default`. +- Configure: + - `Model`: an LLM model that is known to answer Debug Chat. + - `Knowledge Bases`: only when validating RAG behavior. + - `Rerank Model`: leave `None` unless the case explicitly tests reranking. +- Save the pipeline before using Debug Chat. + +## Debug Chat Checks + +Use `Debug Chat` as the primary local-agent validation path. + +For a basic runner check, send a deterministic prompt such as: + +```text +请只回复 OK,用于前端调试测试。 +``` + +For a RAG check, bind a knowledge base containing a unique sentinel and ask for that sentinel. + +For a tool check, ensure the target tool is visible in `/api/v1/tools`, then ask the runner to call it with deterministic input. +Avoid simultaneous fixtures with the same visible tool name. The current MCP fixture uses `qa_mcp_echo` and the plugin fixture uses `qa_plugin_echo` for unambiguous runner checks. If a run returns `qa-plugin-smoke:` during an MCP case, it exercised a plugin tool or stale registration, not the MCP tool. +If the direct MCP fixture passes but `/api/v1/tools` still shows the old MCP name, run `node scripts/e2e/mcp-stdio-register.mjs` to refresh `qa-local-stdio` before rerunning Debug Chat. + +For a multimodal check, upload a small image and ask for a deterministic acknowledgement. Prefer the bundled 64x64 red-square fixture over a 1x1 image because some model providers reject tiny images before the runner path is exercised. + +For a non-streaming check, disable the Debug Chat stream switch before sending the prompt. + +## Timeout And Tool Regression Checks + +When validating runner timeout or SDK deadline changes, confirm `Configuration > AI` renders the runner timeout field and that the saved value is the one used by the run context. The default local-agent timeout is expected to be `300` seconds unless the pipeline overrides it. + +Pair a basic Debug Chat run with a deterministic plugin tool call, for example `qa_plugin_echo`, then correlate the browser response with backend logs. A healthy run shows the tool call started and completed, and does not emit `runner.timeout`, `Action ... timed out`, `All models failed`, `Traceback`, or unexpected `ERROR` lines for the same request. + +## Minimum Regression Gate + +Run these cases before saying the pluginized local-agent behavior is healthy: + +- `local-agent-basic-debug-chat`: basic streaming model invocation. +- `local-agent-effective-prompt-debug-chat`: host effective prompt after PromptPreProcessing reaches the runner. +- `local-agent-rag-debug-chat`: LangRAG retrieval reaches the runner and affects the answer. +- `mcp-stdio-tool-call`: MCP tool discovery and local-agent tool loop. +- `local-agent-plugin-tool-call-debug-chat`: plugin tool discovery and local-agent tool loop. +- `local-agent-multimodal-debug-chat`: uploaded image reaches `ctx.input.contents`. +- `local-agent-rag-multimodal-debug-chat`: RAG retrieval still works when the same user message carries an image. +- `local-agent-nonstreaming-debug-chat`: runner works when the host adapter cannot or should not stream. + +## Pass Criteria + +- The UI shows the user message and a bot response. +- Console has no unexpected React/runtime errors. +- Backend logs show the debug-chat request completed rather than timing out in plugin/runtime calls. +- When testing RAG or tools, the answer contains the expected sentinel or tool result, not a generic explanation. +- Provider errors such as `model_not_found` or `no available channel` are environment/model availability failures. They do not prove MCP, RAG, or local-agent runner failure unless the same model works outside the tested runner path. +- A model that works for basic streaming may still fail for tool-call, multimodal, or non-streaming request shapes. Treat `runner.llm_error` and `runner.tool_loop_error` with `model_not_found`, `invalid api key`, or upstream saturation as environment/model-route failures until retested with a known-good model for that exact shape. + +## Diagnostic API + +API checks are diagnostic only: + +- `GET /api/v1/pipelines/{uuid}` confirms saved runner config. +- `GET /api/v1/tools` confirms available MCP/plugin tools. +- `GET /api/v1/knowledge/bases` confirms available knowledge bases. diff --git a/skills/skills/langbot-testing/references/mcp-stdio-testing.md b/skills/skills/langbot-testing/references/mcp-stdio-testing.md new file mode 100644 index 000000000..c1930b81a --- /dev/null +++ b/skills/skills/langbot-testing/references/mcp-stdio-testing.md @@ -0,0 +1,111 @@ +# MCP Stdio Testing + +Use this reference when validating MCP server creation, tool discovery, and local-agent tool calls. + +## Minimal Fixture + +Use the bundled test server: + +```text +fixtures/mcp/qa_mcp_echo_server.py +``` + +It exposes one tool: + +```text +qa_mcp_echo(text: str) -> str +``` + +Expected tool result: + +```text +qa_mcp_echo: +``` + +Older versions of this fixture used the visible name `qa_echo`, which collides +with the `qa-plugin-smoke` plugin tool. This fixture now uses the unique +`qa_mcp_echo` name. If a run still returns the plugin sentinel: + +```text +qa-plugin-smoke: +``` + +that proves the run used the plugin tool or a stale MCP registration, not the +current MCP fixture. Refresh the MCP server/tool registration before treating +the result as MCP coverage. + +## Browser Flow + +1. Open `LANGBOT_FRONTEND_URL`. +2. Navigate to `MCP Servers`. +3. Create a new MCP server. +4. Set mode to `Stdio`. +5. Fill the command and each argument separately: + - Command: `python` + - Arg 1: absolute path to `fixtures/mcp/qa_mcp_echo_server.py` +6. Click `Test`. +7. Submit the server. +8. Confirm the server page shows `Tools: 1` and `qa_mcp_echo`. + +Do not paste `python ...` into the command field as one string. LangBot stores `command` and `args` separately. + +## Tool Discovery Checks + +- UI: MCP detail page shows status connected and `qa_mcp_echo`. +- API diagnostic: `GET /api/v1/mcp/servers` shows `runtime_info.status=connected`. +- API diagnostic: `GET /api/v1/tools` contains `qa_mcp_echo`. + +## Provider-Independent Fixture Check + +Use this diagnostic before blaming Local Agent or a model provider: + +```bash +node scripts/e2e/mcp-stdio-fixture.mjs +``` + +It launches the bundled stdio server directly, lists tools over MCP, and calls +`qa_mcp_echo` without invoking a LangBot model. A pass proves the fixture and MCP +stdio framing work; it does not prove the provider-backed Local Agent tool loop. + +## LangBot Runtime Registration Check + +Use this diagnostic when the direct fixture passes but LangBot still lists an old +tool name or the saved MCP server may be stale: + +```bash +node scripts/e2e/mcp-stdio-register.mjs +``` + +It upserts `qa-local-stdio` through the authenticated WebUI session, points it at +the bundled `qa_mcp_echo_server.py`, then checks `/api/v1/tools` and the MCP +runtime info. A pass proves LangBot has refreshed the saved server and exposes +`qa_mcp_echo` before any model provider is involved. + +## Local-Agent Tool Call Check + +1. Open the target pipeline. +2. Confirm `Extensions` allows the MCP server, or that all MCP servers are enabled. +3. Use runner `Default` or the pluginized `langbot/local-agent` runner. +4. Select a model with function-calling ability that is known to work with tools in the current environment. +5. Open `Debug Chat`. +6. Ask: + +```text +Call the qa_mcp_echo tool with exactly this text: mcp-ok-local-agent. Return only the tool result. +``` + +Pass when the bot response contains: + +```text +qa_mcp_echo:mcp-ok-local-agent +``` + +Do not count this case as passed when the bot returns: + +```text +qa-plugin-smoke:mcp-ok-local-agent +``` + +That proves a plugin tool was called, not the MCP server. + +If the provider returns `model_not_found` or `no available channel` only when tools are supplied, switch to a known-good function-calling model before diagnosing MCP or local-agent. That failure means the selected model route is unavailable for the requested tool-call shape. diff --git a/skills/skills/langbot-testing/references/model-provider-testing.md b/skills/skills/langbot-testing/references/model-provider-testing.md new file mode 100644 index 000000000..1431d549e --- /dev/null +++ b/skills/skills/langbot-testing/references/model-provider-testing.md @@ -0,0 +1,25 @@ +# Model Provider Testing + +## Goal + +Verify that a model provider can be added, configured with a key, and tested from the WebUI. + +## Rules + +- Never print the API key or token value. +- Prefer using a test key supplied through the user's environment or secret manager. +- After saving a provider, use the WebUI test button when available. +- Confirm the provider is usable by running a small pipeline Debug Chat test, not only by checking that the form saved. + +## DeepSeek Flow + +1. Open `LANGBOT_FRONTEND_URL` from `skills/.env` or the active user-provided environment. +2. Go to `Models`. +3. Add or edit a DeepSeek provider. +4. Fill the required base URL, API key, and model fields according to the current UI. +5. Click the provider/model test button. +6. If the UI test succeeds, verify with a pipeline Debug Chat message. + +## Completion Signal + +Report the provider name, model name, UI test result, and pipeline Debug Chat result. Do not include secrets. diff --git a/skills/skills/langbot-testing/references/performance-reliability-testing.md b/skills/skills/langbot-testing/references/performance-reliability-testing.md new file mode 100644 index 000000000..42aaa0467 --- /dev/null +++ b/skills/skills/langbot-testing/references/performance-reliability-testing.md @@ -0,0 +1,285 @@ +# Performance And Reliability Testing + +Use this reference when a QA request asks whether LangBot is fast enough, +stable under load, or resilient to controlled faults. + +These probes are manual/non-required QA gates unless a case or suite explicitly +states otherwise. They depend on a live local backend, mutable QA fixtures, and +operator-selected environment variables, so do not promote them to required CI +checks until fake-provider isolation, ownership markers, and cleanup are in +place. + +## Scope + +Treat `skills/` as the QA control plane: + +- Cases define intent, readiness, thresholds, and required evidence. +- Probe scripts collect metrics, traces, resource logs, and artifacts. +- Reports classify the same run as `pass`, `fail`, `blocked`, + `env_issue`, or `flaky`. + +Do not turn `skills/` into a load generator or chaos engine. Call a focused +tool from a `mode: probe` case when the test needs one, for example k6, +Locust, pytest-benchmark, Playwright trace collection, Toxiproxy, Docker, or a +Kubernetes disruption tool. + +## LangBot Performance Model + +For LangBot, performance is the cost LangBot adds around external systems: + +```text +LangBot overhead = end-to-end latency - provider latency - external tool latency - network/fault injection latency +``` + +Measure user experience and internal composition separately: + +- WebUI load and interaction latency. +- Debug Chat send-to-first-visible-token and send-to-completion latency. +- Pipeline, RAG, plugin runtime, MCP, AgentRunner, and persistence segment + latency. +- Queue wait time, concurrency, throughput, timeout rate, and p95/p99 latency. +- Startup, plugin install, knowledge-base ingestion, migration, and recovery + time. + +Do not report a single message round-trip time as "LangBot performance" unless +the report also explains external provider/tool/network time. + +## Evidence Contract + +Performance and reliability cases should declare the evidence they need: + +- `metrics`: machine-readable latency, throughput, error-rate, or recovery + metrics, usually `metrics.json`. +- `resource_log`: CPU, memory, process, connection, queue, or file descriptor + samples. +- `trace`: browser, HTTP, database, or runtime trace artifacts. +- `profile`: CPU, memory, or flamegraph profile artifacts. +- `backend_log`, `network`, `api_diagnostic`, and `filesystem` as supporting + evidence when relevant. + +Automation should write `automation-result.json` with these fields when +available: + +```json +{ + "status": "pass", + "reason": "Probe passed all thresholds.", + "metrics_summary": { + "langbot_overhead_p95_ms": 12.4, + "error_rate": 0 + }, + "thresholds_summary": { + "langbot_overhead_p95_ms": { "actual": 12.4, "max": 50, "pass": true } + }, + "artifacts": { + "metrics_json": "/path/to/metrics.json" + }, + "evidence_collected": ["metrics", "filesystem"] +} +``` + +Synthetic contract probes are useful for checking the QA harness, but they are +not live product performance results. Label them as contract probes in the case +title, checks, and report. + +## Chaos And Reliability Rules + +Chaos tests must be narrow and reversible: + +- Declare the fault model in `fault_model_json`. +- Record blast radius, target component, injection method, duration, and abort + conditions. +- Capture recovery checks and cleanup steps in the case. +- Classify unavailable dependencies as `env_issue` unless the target behavior + is LangBot's handling of that dependency failure. +- Do not run destructive fault injection against a shared or production-like + instance without explicit operator approval. + +Recommended first fault models: + +- Provider timeout or HTTP 429 from a fake provider endpoint. +- Plugin runtime disconnect/reconnect in a local instance. +- MCP stdio server exits mid-call. +- RAG parser fixture fails once and recovers on retry. +- Backend API endpoint returns 5xx from a controlled local proxy. + +## Starter Live Probes + +The starter gate separates QA-harness contracts from live product checks: + +- `langbot-overhead-accounting-contract` verifies that reports can carry + overhead accounting metrics. It uses deterministic synthetic samples and is + not live product performance. +- `langbot-fault-taxonomy-contract` verifies that fault scenarios declare + expected status, recovery, and cleanup before destructive chaos tests are + added. +- `langbot-live-backend-latency` checks the unauthenticated `/healthz` + endpoint for basic backend responsiveness. +- `langbot-live-control-plane-api` checks `/healthz` and + `/api/v1/system/info` for HTTP 200, JSON `code: 0`, response shape, and + per-endpoint p95 latency. +- `langbot-live-backend-log-health` scans the recent backend log window for + fail-severity runtime findings. It is the reliability guard that should fail + the gate when HTTP probes pass but backend logs contain Traceback, ImportError, + ERROR, unclosed sessions, or unawaited coroutine signals. + +Do not treat these starter live probes as Debug Chat or model-provider +performance. They are control-plane readiness checks; user-facing performance +needs browser/WebSocket/message-path measurements. + +## Debug Chat Load And Fake Provider Baseline + +Use `langbot-fake-provider-debug-chat-load` before real-provider load checks. +The setup automation starts a local OpenAI-compatible fake provider, registers +it as a normal LangBot provider/model, configures a local-agent pipeline, resets +Debug Chat, and then drives concurrent WebSocket messages through the live +backend. + +This is not a mocked backend test. It still exercises: + +- provider/model persistence and runtime reload; +- LiteLLM OpenAI-compatible requester path; +- local-agent runner selection and pipeline execution; +- Debug Chat WebSocket adapter and broadcast behavior; +- backend concurrency, timeout, and error-rate accounting. + +The fake provider is deterministic and can inject controlled latency or faults +with `LANGBOT_FAKE_PROVIDER_*` variables, so it is the baseline for LangBot +message-path overhead. A fake-provider process keeps process-global config, +request counters, and recent request history; run fake-provider probes serially +or give each run its own provider instance. Concurrent probes against the same +fake-provider URL can reset or reconfigure each other's metrics. + +The probe uses unique expected response tokens per +request because Debug Chat broadcasts messages to every connection in the same +session; unique tokens prevent one connection from counting another +connection's response as its own. + +When the fake provider is used, reports also include provider-side timing in +`metrics.json`: + +- `fake_provider.duration_ms` and `fake_provider.first_content_chunk_ms` + measure the controlled provider itself. +- `provider_timing.send_to_provider_start_ms` estimates WebSocket ingress, + pipeline dispatch, runner setup, and requester time before the provider + receives the request. +- `provider_timing.provider_finish_to_ws_final_ms` estimates the path from + provider completion back to the final Debug Chat WebSocket response. +- `provider_timing.langbot_overhead_estimate_ms` is the sum of those two + LangBot-side segments when wall-clock timestamps can be matched by the + unique expected response token. + +After the baseline passes, run `langbot-fake-provider-debug-chat-slow-load` to +keep the same live backend path while injecting deterministic streaming latency. +Run `langbot-fake-provider-debug-chat-fault-recovery` to inject bounded HTTP +provider failures and require both observed failures and later successful +requests. The fault-recovery case is deliberately sequential because failed +Debug Chat responses do not carry a unique success token that can be attributed +to one concurrent connection. + +Run `langbot-fake-provider-debug-chat-cross-pipeline-isolation` separately via +`langbot-debug-chat-isolation-gate`. Current LangBot releases may fail it because +of product bug [#2286](https://github.com/langbot-app/LangBot/issues/2286), where +Debug Chat replies can read singleton WebSocket proxy pipeline state after a +later message overwrites it. Treat that failure as regression evidence for the +product fix rather than as a fake-provider latency finding. + +Use `langbot-space-debug-chat-concurrency-smoke` after the fake-provider +baseline. It runs a deliberately small real Space-provider batch and reports +user-visible latency, not pure LangBot overhead. Space/model/network failures +are dependency findings until the fake baseline shows the same symptom. +If a Space smoke passes but log guard finds telemetry posting Tracebacks, +classify that separately as `telemetry-proxy-noise` instead of clearing the +proxy or treating the Debug Chat path as failed. + +Useful commands: + +```bash +rtk bin/lbs test run langbot-fake-provider-debug-chat-load --run-id langbot-fake-load-local +rtk bin/lbs test run langbot-fake-provider-debug-chat-slow-load --run-id langbot-fake-slow-local +rtk bin/lbs test run langbot-fake-provider-debug-chat-fault-recovery --run-id langbot-fake-fault-local +rtk bin/lbs suite run langbot-debug-chat-isolation-gate --run-id langbot-debug-chat-isolation-local --include-manual-check +rtk bin/lbs test run langbot-space-debug-chat-concurrency-smoke --run-id langbot-space-smoke-local +rtk bin/lbs suite run langbot-debug-chat-load-gate --run-id langbot-debug-chat-load-local --include-manual-check +``` + +## Gate Layers + +Use the smallest gate that answers the quality question: + +- `langbot-performance-contract-gate`: fast synthetic checks for report shape, + threshold accounting, and fault taxonomy. Good for PR feedback when no live + service is running. +- `langbot-live-backend-gate`: live backend `/healthz`, + `/api/v1/system/info`, and backend log health. Good after starting a local + LangBot backend. +- `langbot-user-path-performance-gate`: browser-visible user path performance, + starting with Pipeline Debug Chat send-to-visible-completion latency. Run it + only when the browser profile and target pipeline are ready. +- `langbot-debug-chat-load-gate`: manual WebSocket Debug Chat load checks, + starting with controlled fake-provider baseline, slow-provider, and + fault-recovery profiles, plus an optional low-volume real Space-provider + smoke. Run fake-provider cases serially when they share a provider URL. +- `langbot-debug-chat-isolation-gate`: manual cross-pipeline Debug Chat + isolation regression gate. Current releases may fail because of #2286; keep it + separate from the normal load gate until that product fix lands. +- `langbot-performance-reliability-gate`: combined starter gate for synthetic + contracts plus live backend checks. + +Keep environment diagnostics separate from product regressions. For example, a +SOCKS proxy without Python `socksio` support should be fixed or clearly +classified by `bin/lbs env doctor`; do not hide the resulting backend +Traceback in reports. + +## Debug Chat Performance + +`pipeline-debug-chat-performance` reuses the browser Debug Chat automation and +adds `metrics.json`, `metrics_summary`, and `thresholds_summary` to +`automation-result.json`. + +Current metric: + +```text +response_duration_ms = prompt send -> expected assistant response visible and stable +``` + +This is a user-path metric, not pure LangBot overhead. If it regresses, inspect +provider latency, model route health, plugin/runtime logs, WebSocket behavior, +and browser console/network evidence before attributing the whole duration to +LangBot. + +### User-Path Gate Runbook + +1. Start the backend and frontend. The frontend must be launched with + `VITE_API_BASE_URL="$LANGBOT_BACKEND_URL"` so browser API calls reach the + backend. +2. Run `node scripts/e2e/ensure-local-agent-pipeline.mjs --write-env`. The + setup refreshes the local QA login, skips the wizard, prepares a Debug Chat + pipeline, scans Space models, tests candidates, writes tested fallback + models, and writes the selected pipeline/model env values to + `skills/.env.local`. +3. If setup returns `env_issue`, read `model_tests` and provider errors first. + A missing Space key, failed Space scan, or unavailable model route is not a + LangBot performance regression. +4. Run + `bin/lbs suite run langbot-user-path-performance-gate --include-manual-check`. +5. Interpret `response_p95_ms` as browser-visible send-to-completion time. It + includes provider latency; use backend logs and model test evidence to + separate LangBot overhead from the external model route. + +The setup keeps a `max-round` value in the generated pipeline config only +because the current backend truncator still reads that field directly. Do not +use it as a quality requirement for future local-agent behavior. + +## Running The First Gate + +Start with the reusable suite: + +```bash +rtk bin/lbs suite plan langbot-performance-reliability-gate +rtk bin/lbs suite start langbot-performance-reliability-gate --run-id langbot-perf-rel-local +``` + +Run synthetic contract probes first. Run live probes only after the selected +backend/frontend instance is reachable and the run owner accepts any fault +scope. diff --git a/skills/skills/langbot-testing/references/pipeline-debug-chat.md b/skills/skills/langbot-testing/references/pipeline-debug-chat.md new file mode 100644 index 000000000..7059c1bcd --- /dev/null +++ b/skills/skills/langbot-testing/references/pipeline-debug-chat.md @@ -0,0 +1,53 @@ +# Pipeline Debug Chat + +## Goal + +Verify that a pipeline can receive a private debug message and return a bot response through the configured frontend. + +## Path + +1. Open `LANGBOT_FRONTEND_URL` from `skills/.env` or the active user-provided environment. +2. Navigate to `Pipelines`. +3. Open the target pipeline. +4. Select `Debug Chat`. +5. Send a short deterministic prompt, for example: + + ```text + 请只回复 OK,用于前端调试测试。 + ``` + +## Success Criteria + +The UI should show: + +- A `User` message containing the prompt. +- A `Bot` message containing the expected response, for example `OK`. + +When the prompt itself contains a sentinel token, do not treat `document.body` containing that token as success. Confirm the token appears in a `Bot`/assistant message, WebSocket history entry, or backend completion log. + +For `scripts/e2e/pipeline-debug-chat.mjs`, inspect +`automation-result.json` when a sentinel is present in the prompt. A pass should +show the expected text in a new assistant message; the +`after_assistant_expected_count` value must increase beyond +`before_assistant_expected_count`. If only the user prompt contains the +sentinel, the run is a failure even when the page body contains enough total +occurrences. + +The backend log should include: + +```text +Processing request from person_websocket... +Streaming completed +``` + +## Failure Criteria + +Treat the test as failed if: + +- Only the user message appears. +- The page shows `Agent runner temporarily unavailable`. +- Backend logs contain `All models failed during streaming setup`. +- Backend logs contain `Action invoke_llm_stream call timed out`. +- Backend logs contain `Action list_plugins call timed out`. + +When failures match these signatures, read `troubleshooting.md`. diff --git a/skills/skills/langbot-testing/references/plugin-e2e-smoke.md b/skills/skills/langbot-testing/references/plugin-e2e-smoke.md new file mode 100644 index 000000000..c77cf36f7 --- /dev/null +++ b/skills/skills/langbot-testing/references/plugin-e2e-smoke.md @@ -0,0 +1,66 @@ +# Plugin E2E Smoke + +Use this reference to validate LangBot plugin behavior with a browser-first flow and API/log diagnostics. + +## Fixture + +Use the bundled local plugin source: + +```text +fixtures/plugins/qa-plugin-smoke +``` + +It registers: + +- Plugin id: `qa/plugin-smoke` +- Tool: `qa_echo(text: string)` returning `qa-plugin-smoke:` +- Tool: `qa_plugin_echo(text: string)` returning `qa-plugin-smoke:` +- Tool: `qa_plugin_sleep(seconds: number, text: string)` returning `qa-plugin-smoke:sleep::` after a bounded delay +- Page: `smoke`, with an HTML asset and a backend page API sentinel `qa-plugin-smoke-page` + +## SDK Under Test + +When validating a local SDK build, install it into the LangBot worktree virtualenv: + +```bash +cd "$LANGBOT_REPO" +uv pip install -e /absolute/path/to/langbot-plugin-sdk-test-build +uv run --no-sync python -c "import langbot_plugin, pathlib; print(pathlib.Path(langbot_plugin.__file__).resolve())" +``` + +The printed path must point into the local SDK source tree. Use `uv run --no-sync` for LangBot startup and tests; plain `uv run` may sync the lockfile and restore the PyPI package. + +## Build The Fixture + +From the fixture directory, build with the same SDK that LangBot will run: + +```bash +cd skills/langbot-testing/fixtures/plugins/qa-plugin-smoke +"$LANGBOT_REPO/.venv/bin/lbp" build +``` + +The generated zip under `dist/` is the file to upload from the WebUI. + +## Browser Flow + +1. Start or verify backend and frontend. +2. Open `LANGBOT_FRONTEND_URL`. +3. Initialize or log in to the test instance. +4. Navigate to `Plugins`. +5. Choose local plugin install and upload the generated `qa-plugin-smoke` zip. +6. Wait for the install task to finish. +7. Confirm the plugin list/detail shows `QA Plugin Smoke`, `qa_echo`, and `Smoke Page`. +8. Open the plugin extension page if it appears in the sidebar and verify it renders the sentinel text. + +## Diagnostic Checks + +Use API checks only to confirm what the UI exercised: + +- `GET /api/v1/plugins` contains `qa/plugin-smoke` with initialized status. +- `GET /api/v1/tools` contains `qa_echo`, `qa_plugin_echo`, and `qa_plugin_sleep`. +- `POST /api/v1/plugins/qa/plugin-smoke/page-api` with `page_id=smoke`, `endpoint=/ping`, `method=GET` returns `qa-plugin-smoke-page`. +- Backend logs include `Connected to plugin runtime` and no `Action ... call timed out` entries. + +## Cleanup + +Delete `qa/plugin-smoke` through the WebUI or `DELETE /api/v1/plugins/qa/plugin-smoke?delete_data=true` after recording results. diff --git a/skills/skills/langbot-testing/references/sandbox-skill-authoring.md b/skills/skills/langbot-testing/references/sandbox-skill-authoring.md new file mode 100644 index 000000000..db9b82647 --- /dev/null +++ b/skills/skills/langbot-testing/references/sandbox-skill-authoring.md @@ -0,0 +1,136 @@ +# Sandbox Skill Authoring + +## Goal + +Verify that Local Agent can use sandbox tools to create, register, activate, and use a LangBot skill package through the same path a user would exercise in Debug Chat. + +This flow applies to Docker, nsjail, and E2B backends. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence. + +## Preconditions + +1. Read `../.env` and use `LANGBOT_FRONTEND_URL` and `LANGBOT_BACKEND_URL`. +2. Start LangBot with the intended backend: + - `BOX_BACKEND=e2b` when validating E2B. + - `BOX_BACKEND=nsjail` when validating nsjail. + - `BOX_BACKEND=local` or `docker` when validating local container fallback. +3. Confirm `/api/v1/box/status` reports `available: true` and the expected backend name. +4. Confirm Debug Chat uses a model with function-calling ability. +5. Confirm backend logs say native sandbox tools are available. + +Do not store sandbox provider keys, JWTs, OAuth tokens, or localStorage values in the case or notes. + +## Debug Chat Prompt Pattern + +Use a unique skill name per run, for example: + +```text +lb-sandbox-agent-e2e- +``` + +Send a prompt that requires the model to do all of the following: + +1. Use `exec` to create a multi-file skill under `/workspace/`. +2. Include at least: + - `SKILL.md` + - `scripts/use.py` + - `data/input.json` +3. In the same first `exec`, run the script and verify a deterministic marker such as: + + ```text + SANDBOX_COMPLEX_SKILL_OK sum=10 product=24 + ``` + +4. Call `register_skill` with `path=/workspace/`. +5. Call `activate` with `skill_name=`. +6. Call `exec` with `workdir=/workspace/.skills/` and run: + + ```bash + python3 scripts/use.py && echo SANDBOX_ACTIVATED_WRITEBACK_OK > activated_writeback.txt && cat activated_writeback.txt + ``` + +7. Require the final answer to contain only an explicit success marker: + + ```text + E2E_OK: + ``` + +Keep the test script robust to working-directory changes. Prefer resolving data paths from `__file__`: + +```python +from pathlib import Path +data_path = Path(__file__).resolve().parent.parent / "data" / "input.json" +``` + +## Success Criteria + +The UI should show an assistant final response containing `E2E_OK:`. + +Backend logs should show: + +- `exec tool invoked` +- `register_skill` +- `activate` +- a second `exec` whose workdir is `/workspace/.skills/` +- `backend=e2b`, `backend=nsjail`, or the expected local backend + +After the run, verify the skill store through the UI or API: + +- Skill root lists `SKILL.md`, `scripts`, `data`, and `activated_writeback.txt`. +- `scripts/use.py` is readable. +- `data/input.json` is readable. +- `activated_writeback.txt` contains `SANDBOX_ACTIVATED_WRITEBACK_OK`. +- `/api/v1/box/errors` is empty. + +## Existing Skill Edit Variant + +The base `sandbox-skill-authoring-e2e` case only proves create, register, +activate, and use. To prove that an already activated skill can be modified, +run `sandbox-skill-authoring-edit-existing-e2e` or use its prompt pattern. + +The edit variant must include these additional checks: + +- The second `exec` uses `workdir=/workspace/.skills/`. +- The second `exec` overwrites `SKILL.md`, `data/input.json`, and + `scripts/use.py` under the activated skill path. +- The modified script prints a deterministic marker such as: + + ```text + SANDBOX_SKILL_MODIFIED_OK sum=11 product=56 marker= + ``` + +- `grep -q SKILL.md scripts/use.py data/input.json` succeeds + in the same activated-path command. +- Filesystem evidence under the Box-managed skill store shows the updated + marker, not only the original create marker. + +If the model stops after activation and only reruns the original script, treat +that run as a failed edit-existing E2E even when create/register/activate +succeeded. + +## Diagnostic Checks + +Use these only after the model-driven Debug Chat flow fails: + +- `/api/v1/box/status` to confirm backend selection and recent errors. +- `/api/v1/box/sessions` to check leaked or conflicting sessions. +- Direct backend probes to separate provider credentials from LangBot integration. +- Filesystem inspection under the configured Box-managed skill store. + +For E2B raw HTTP diagnostics, include a valid template id such as `base`; a missing template can produce schema validation errors that are unrelated to authentication. + +## Known Pitfalls + +- When Box is available, skills may be owned by the Box runtime and stored in Box-managed skill storage. Do not assume `data/skills` is the active source of truth. +- Public E2B does not provide local bind mounts. Main workspace and activated skill extra mounts must be synchronized into and back out of the E2B sandbox. +- Session metadata should keep LangBot logical paths such as `/workspace`; storing provider-internal paths can make later requests look incompatible. +- nsjail versions differ. Some expose only `--disable_clone_new*` flags and use `--bindmount` instead of `--rw_bind`. +- On WSL, cgroup v2 may exist but not be writable. The backend should warn and fall back to rlimits rather than fail the sandbox. +- If `ALL_PROXY` uses a SOCKS URL and `socksio` is not installed, some Python HTTP clients can fail during startup. Prefer consistent HTTP proxy variables unless SOCKS support is installed. + +## Related Troubleshooting + +- `sandbox-native-tools-unavailable` +- `e2b-extra-mount-sync-missing` +- `box-session-conflict-logical-metadata` +- `nsjail-cli-compatibility` +- `socks-proxy-without-socksio` diff --git a/skills/skills/langbot-testing/references/troubleshooting.md b/skills/skills/langbot-testing/references/troubleshooting.md new file mode 100644 index 000000000..286990c59 --- /dev/null +++ b/skills/skills/langbot-testing/references/troubleshooting.md @@ -0,0 +1,91 @@ +# Troubleshooting + +Troubleshooting entries are now managed as structured YAML under `../troubleshooting/`. Use `bin/lbs trouble add langbot-testing ...` to add new entries when a new failure mode is confirmed. + +This Markdown file is a human-readable legacy summary. Prefer the YAML entries for automation. + +## plugin-runtime-timeout: Plugin runtime actions time out + +Structured entry: `../troubleshooting/plugin-runtime-timeout.yaml` + +Date: 2026-05-16 + +### Symptom + +The WebUI can send a Debug Chat message, but the bot response is missing or says `Agent runner temporarily unavailable`. Backend logs may include `Action list_plugins call timed out`, `Action list_agent_runners call timed out`, or `Action invoke_llm_stream call timed out`. + +### Likely Cause + +An old `langbot_plugin` runtime process survived a backend restart, or multiple runtime processes are active at once. The backend is running, but plugin actions do not get a valid response. + +### Fix + +Stop the LangBot backend and any orphaned `langbot_plugin.cli` runtime processes, confirm the configured backend URL is free/reachable as appropriate, then start LangBot again. A healthy startup logs `Connected to plugin runtime`, mounts `langbot/local-agent`, and initializes the default agent runner. + +### Verification + +Run a pipeline Debug Chat prompt. The UI should show a Bot response, and backend logs should include `Streaming completed`. + +## proxy-env-mismatch: Uppercase and lowercase proxy variables differ + +Structured entry: `../troubleshooting/proxy-env-mismatch.yaml` + +Date: 2026-05-16 + +### Symptom + +External model calls time out even though the proxy appears to be configured. Environment inspection shows uppercase proxy variables using `127.0.0.1:7890` while lowercase variables still point to an old WSL gateway such as `172.30.144.1:7890`. + +### Likely Cause + +Different libraries read different proxy variable names. If lowercase and uppercase values differ, model/provider calls can use the wrong proxy. + +### Fix + +Start LangBot with consistent `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `http_proxy`, `https_proxy`, `all_proxy`, `NO_PROXY`, and `no_proxy` values. + +### Verification + +Backend startup should no longer show old proxy addresses, and a pipeline Debug Chat should complete with a Bot response. + +## mcp-stdio-args-not-applied: MCP Stdio test runs only the command without arguments + +Structured entry: `../troubleshooting/mcp-stdio-args-not-applied.yaml` + +The MCP form may appear filled correctly, but the backend logs show bare `uv` help text or `Connection closed`. Confirm command and args are split correctly, then check whether the form test handler reads the latest stdio args. + +## survey-widget-blocks-debug-chat: Survey widget blocks Debug Chat controls + +Structured entry: `../troubleshooting/survey-widget-blocks-debug-chat.yaml` + +If Playwright cannot click Debug Chat `Send` because a fixed bottom-right element intercepts pointer events, close or minimize the survey widget before continuing the browser test. + +## dynamic-form-missing-config-id: Dynamic form fields have no stable key + +Structured entry: `../troubleshooting/dynamic-form-missing-config-id.yaml` + +Dynamic plugin/runner schemas can trigger React unique-key warnings when schema items lack `id`. Prefer backend-generated stable ids and keep frontend fallback keys. + +## pipeline-form-controlled-warning: Pipeline form input switches from uncontrolled to controlled + +Structured entry: `../troubleshooting/pipeline-form-controlled-warning.yaml` + +Pipeline edit forms should initialize string fields to empty strings and coalesce loaded nullable values before rendering inputs. + +## marketplace-network-flaky: Marketplace requests are flaky but plugin data may still load + +Structured entry: `../troubleshooting/marketplace-network-flaky.yaml` + +Marketplace icon/tag/recommendation requests can fail while plugin cards are already visible. Retry first, and use backend component endpoints only to confirm installation results. + +## agent-runner-actor-context-fields: AgentRunner reads old actor fields + +Structured entry: `../troubleshooting/agent-runner-actor-context-fields.yaml` + +If Debug Chat fails and logs include `ActorContext` missing `type` or `id`, update runner code to use protocol v1 fields `actor_type` and `actor_id`. + +## ambiguous-runner-default-label: Runner selector shows multiple Default options + +Structured entry: `../troubleshooting/ambiguous-runner-default-label.yaml` + +Keep `metadata.name: default` for stable component ids, but set user-facing `metadata.label` to provider-specific names such as `Dify` or `本地 Agent`. diff --git a/skills/skills/langbot-testing/references/web-ui-testing.md b/skills/skills/langbot-testing/references/web-ui-testing.md new file mode 100644 index 000000000..8a2421f13 --- /dev/null +++ b/skills/skills/langbot-testing/references/web-ui-testing.md @@ -0,0 +1,37 @@ +# Web UI Testing + +## Baseline + +- Read shared defaults from `skills/.env`. +- Open `LANGBOT_FRONTEND_URL`. +- Use `LANGBOT_BACKEND_URL` for backend/API/log checks. +- Use Playwright MCP or another browser automation tool with a persisted authenticated profile. + +## Workflow + +1. Start or verify the backend. +2. Start or verify the selected frontend. +3. Open `LANGBOT_FRONTEND_URL`. +4. Confirm the sidebar shows the logged-in user instead of the login page. +5. Navigate through the target flow with role/text selectors where possible. +6. Check browser console errors, visible UI state, and backend logs. + +## Browser Vs API Boundary + +Use browser automation as the acceptance path for WebUI cases. API or curl checks are useful for readiness, saved config inspection, and log correlation, but they do not cover login state, form rendering, frontend save behavior, websocket streaming, or console regressions. + +For a UI case, curl can support the report but cannot make the case pass by itself. A passing report should include the visible browser result and any backend/API diagnostics that explain the same run. + +## Authentication Notes + +If the user logged in on one origin but `LANGBOT_FRONTEND_URL` still shows `/login`, copy only the auth state needed for the selected origin. Do not print token values. + +## Completion Signal + +Report: + +- URL tested. +- User action performed. +- Visible result. +- Backend or network confirmation. +- Any console/backend errors that remain. diff --git a/skills/skills/langbot-testing/suites/agent-runner-release-gate.yaml b/skills/skills/langbot-testing/suites/agent-runner-release-gate.yaml new file mode 100644 index 000000000..a2c150669 --- /dev/null +++ b/skills/skills/langbot-testing/suites/agent-runner-release-gate.yaml @@ -0,0 +1,38 @@ +id: agent-runner-release-gate +title: "Agent runner externalization release gate" +description: "Release gate for runner externalization: pytest probes, environment preflight, fixture registration, local-agent capability paths, and ACP external harness execution." +type: release_gate +priority: p0 +tags: + - agent-runner + - release-gate + - local-agent + - external-runner +cases: + - agent-runner-fixture-contract + - agent-runner-behavior-matrix + - agent-runner-ledger-invariants + - agent-runner-async-db-readiness + - agent-runner-ledger-stress + - agent-runner-ledger-contention + - agent-runner-ledger-concurrency + - agent-runner-runtime-chaos + - agent-runner-live-install + - agent-runner-qa-debug-chat + - agent-runner-release-preflight + - webui-login-state + - pipeline-debug-chat + - qa-plugin-smoke-live-install + - plugin-e2e-smoke + - langrag-kb-retrieve + - local-agent-basic-debug-chat + - local-agent-effective-prompt-debug-chat + - local-agent-context-compaction-debug-chat + - local-agent-rag-debug-chat + - local-agent-plugin-tool-call-debug-chat + - mcp-stdio-register + - mcp-stdio-tool-call + - local-agent-nonstreaming-debug-chat + - local-agent-multimodal-debug-chat + - local-agent-rag-multimodal-debug-chat + - acp-agent-runner-debug-chat diff --git a/skills/skills/langbot-testing/suites/core-smoke.yaml b/skills/skills/langbot-testing/suites/core-smoke.yaml new file mode 100644 index 000000000..d0cfe9127 --- /dev/null +++ b/skills/skills/langbot-testing/suites/core-smoke.yaml @@ -0,0 +1,13 @@ +id: core-smoke +title: "Core browser smoke suite" +description: "Fast browser-first checks for login state, Pipeline Debug Chat, and the basic local-agent runner path." +type: smoke +priority: p0 +tags: + - smoke + - browser + - p0 +cases: + - webui-login-state + - pipeline-debug-chat + - local-agent-basic-debug-chat diff --git a/skills/skills/langbot-testing/suites/langbot-debug-chat-isolation-gate.yaml b/skills/skills/langbot-testing/suites/langbot-debug-chat-isolation-gate.yaml new file mode 100644 index 000000000..d2b31dd32 --- /dev/null +++ b/skills/skills/langbot-testing/suites/langbot-debug-chat-isolation-gate.yaml @@ -0,0 +1,13 @@ +id: langbot-debug-chat-isolation-gate +title: "LangBot Debug Chat isolation gate" +description: "Manual/non-required cross-pipeline Debug Chat isolation gate. Current releases may fail this gate because of product bug #2286; use it as regression evidence after the routing fix lands." +type: reliability +priority: p1 +tags: + - reliability + - debug-chat + - websocket + - isolation + - concurrency +cases: + - langbot-fake-provider-debug-chat-cross-pipeline-isolation diff --git a/skills/skills/langbot-testing/suites/langbot-debug-chat-load-gate.yaml b/skills/skills/langbot-testing/suites/langbot-debug-chat-load-gate.yaml new file mode 100644 index 000000000..5b4950f16 --- /dev/null +++ b/skills/skills/langbot-testing/suites/langbot-debug-chat-load-gate.yaml @@ -0,0 +1,15 @@ +id: langbot-debug-chat-load-gate +title: "LangBot Debug Chat load gate" +description: "Manual/non-required message-path load checks for Pipeline Debug Chat: controlled fake-provider baseline, slow-provider and fault-recovery profiles, plus optional real Space-provider smoke. Cross-pipeline isolation is split into langbot-debug-chat-isolation-gate because current releases may fail it due to product bug #2286." +type: performance +priority: p1 +tags: + - performance + - debug-chat + - websocket + - load +cases: + - langbot-fake-provider-debug-chat-load + - langbot-fake-provider-debug-chat-slow-load + - langbot-fake-provider-debug-chat-fault-recovery + - langbot-space-debug-chat-concurrency-smoke diff --git a/skills/skills/langbot-testing/suites/langbot-live-backend-gate.yaml b/skills/skills/langbot-testing/suites/langbot-live-backend-gate.yaml new file mode 100644 index 000000000..58a978527 --- /dev/null +++ b/skills/skills/langbot-testing/suites/langbot-live-backend-gate.yaml @@ -0,0 +1,14 @@ +id: langbot-live-backend-gate +title: "LangBot live backend reliability gate" +description: "Live backend control-plane responsiveness and runtime log health checks for a locally running LangBot instance." +type: reliability +priority: p1 +tags: + - performance + - reliability + - live-backend + - metrics +cases: + - langbot-live-backend-latency + - langbot-live-control-plane-api + - langbot-live-backend-log-health diff --git a/skills/skills/langbot-testing/suites/langbot-performance-contract-gate.yaml b/skills/skills/langbot-testing/suites/langbot-performance-contract-gate.yaml new file mode 100644 index 000000000..b5a9eb47f --- /dev/null +++ b/skills/skills/langbot-testing/suites/langbot-performance-contract-gate.yaml @@ -0,0 +1,13 @@ +id: langbot-performance-contract-gate +title: "LangBot performance contract gate" +description: "Fast synthetic contract checks for performance metric accounting and non-destructive reliability fault taxonomy." +type: contract +priority: p1 +tags: + - performance + - reliability + - contract + - metrics +cases: + - langbot-overhead-accounting-contract + - langbot-fault-taxonomy-contract diff --git a/skills/skills/langbot-testing/suites/langbot-performance-reliability-gate.yaml b/skills/skills/langbot-testing/suites/langbot-performance-reliability-gate.yaml new file mode 100644 index 000000000..1e0d58d26 --- /dev/null +++ b/skills/skills/langbot-testing/suites/langbot-performance-reliability-gate.yaml @@ -0,0 +1,16 @@ +id: langbot-performance-reliability-gate +title: "LangBot performance and reliability starter gate" +description: "Starter gate for LangBot performance accounting, live backend control-plane latency, and non-destructive fault taxonomy checks." +type: reliability +priority: p1 +tags: + - performance + - reliability + - metrics + - chaos +cases: + - langbot-overhead-accounting-contract + - langbot-fault-taxonomy-contract + - langbot-live-backend-latency + - langbot-live-control-plane-api + - langbot-live-backend-log-health diff --git a/skills/skills/langbot-testing/suites/langbot-user-path-performance-gate.yaml b/skills/skills/langbot-testing/suites/langbot-user-path-performance-gate.yaml new file mode 100644 index 000000000..a6a138ec0 --- /dev/null +++ b/skills/skills/langbot-testing/suites/langbot-user-path-performance-gate.yaml @@ -0,0 +1,12 @@ +id: langbot-user-path-performance-gate +title: "LangBot user-path performance gate" +description: "Browser-visible performance checks for user-facing LangBot paths such as Pipeline Debug Chat." +type: performance +priority: p1 +tags: + - performance + - browser + - debug-chat + - user-path +cases: + - pipeline-debug-chat-performance diff --git a/skills/skills/langbot-testing/suites/local-agent-gate.yaml b/skills/skills/langbot-testing/suites/local-agent-gate.yaml new file mode 100644 index 000000000..42eee0228 --- /dev/null +++ b/skills/skills/langbot-testing/suites/local-agent-gate.yaml @@ -0,0 +1,21 @@ +id: local-agent-gate +title: "Local Agent runner regression gate" +description: "High-signal local-agent runner checks covering prompt bridge, RAG, context compaction, plugin tools, MCP tools, non-streaming, and multimodal paths." +type: regression +priority: p1 +tags: + - local-agent + - agent-runner + - regression +cases: + - local-agent-basic-debug-chat + - qa-plugin-smoke-live-install + - local-agent-effective-prompt-debug-chat + - local-agent-context-compaction-debug-chat + - local-agent-rag-debug-chat + - local-agent-plugin-tool-call-debug-chat + - local-agent-steering-debug-chat + - mcp-stdio-tool-call + - local-agent-nonstreaming-debug-chat + - local-agent-multimodal-debug-chat + - local-agent-rag-multimodal-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/agent-runner-actor-context-fields.yaml b/skills/skills/langbot-testing/troubleshooting/agent-runner-actor-context-fields.yaml new file mode 100644 index 000000000..48c980197 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/agent-runner-actor-context-fields.yaml @@ -0,0 +1,22 @@ +id: agent-runner-actor-context-fields +title: "AgentRunner reads old actor.type and actor.id fields" +date: 2026-05-17 +symptoms: + - "Pipeline Debug Chat shows Agent runner execution failed." + - "Backend logs show an AttributeError from an AgentRunner plugin." +patterns: + - "AttributeError: 'ActorContext' object has no attribute 'type'" + - "AttributeError: 'ActorContext' object has no attribute 'id'" + - "return f\"{actor.type}_{actor.id}\"" +likely_causes: + - "The plugin was written against old actor field names." + - "Protocol v1 ActorContext uses actor_type and actor_id." +fix_steps: + - "Update runner code to read actor.actor_type and actor.actor_id." + - "Keep getattr fallback to type/id only if compatibility with older host data is required." + - "Restart LangBot or the plugin runtime so the updated plugin code is loaded." + - "Add a regression test that builds AgentRunContext with ActorContext(actor_type=..., actor_id=...)." +verification: "Run dify-agent-debug-chat or another AgentRunner Debug Chat and confirm the assistant/bot message contains the expected sentinel while backend logs show Streaming completed." +related_cases: + - dify-agent-debug-chat + - pipeline-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/aiosqlite-connect-hangs.yaml b/skills/skills/langbot-testing/troubleshooting/aiosqlite-connect-hangs.yaml new file mode 100644 index 000000000..796a6dbac --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/aiosqlite-connect-hangs.yaml @@ -0,0 +1,23 @@ +id: aiosqlite-connect-hangs +title: "aiosqlite connect hangs before ledger pytest starts" +category: env_issue +symptoms: + - "AgentRunner ledger pytest probe times out after collecting tests but before reporting a test result." + - "pytest stdout stops at a line like tests/unit_tests/agent/test_run_ledger_store.py." + - "A direct aiosqlite.connect(':memory:') script prints its first line and then hangs." +patterns: + - "pytest timed out after" + - "tests/unit_tests/agent/test_run_ledger_store.py" + - "aiosqlite.connect" +likely_causes: + - "The current execution environment cannot complete aiosqlite connection startup." + - "The failure is below LangBot ledger logic when direct aiosqlite connection also hangs." + - "Threading and synchronous sqlite can still work, so this is narrower than a general Python or SQLite outage." +fix_steps: + - "Run `rtk bin/lbs test run agent-runner-ledger-invariants` first for fast ledger schema/status coverage that does not use aiosqlite." + - "Classify `agent-runner-ledger-concurrency` timeout as env_issue when direct aiosqlite.connect also hangs." + - "Retest the async pytest target in an environment where aiosqlite connect completes." +verification: "A direct aiosqlite.connect(':memory:') script completes, then `rtk bin/lbs test run agent-runner-ledger-concurrency` no longer times out." +related_cases: + - agent-runner-ledger-concurrency + - agent-runner-ledger-invariants diff --git a/skills/skills/langbot-testing/troubleshooting/ambiguous-runner-default-label.yaml b/skills/skills/langbot-testing/troubleshooting/ambiguous-runner-default-label.yaml new file mode 100644 index 000000000..74e03df3d --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/ambiguous-runner-default-label.yaml @@ -0,0 +1,22 @@ +id: ambiguous-runner-default-label +title: "AgentRunner selector shows multiple Default or 默认 options" +date: 2026-05-17 +symptoms: + - "The Pipeline AI runner selector shows multiple options named Default or 默认." + - "Users must inspect plugin ids to distinguish Dify, Local Agent, or other runners." +patterns: + - "metadata.name: default" + - "label.zh_Hans: 默认" + - "label.en_US: Default" +likely_causes: + - "AgentRunner component ids are commonly named default, but the user-facing metadata.label was also left generic." + - "The frontend displays metadata.label as the primary option label." +fix_steps: + - "Keep metadata.name as default if the plugin component id is intended to remain stable." + - "Change metadata.label to the provider or runner family name, such as Dify, Local Agent, Coze, DashScope, Langflow, n8n, or Tbox." + - "Restart LangBot or plugin runtime to refresh runner metadata cache." + - "Check /api/v1/pipelines/_/metadata and the WebUI runner selector." +verification: "Runner options are distinguishable by visible label while their ids remain stable, such as plugin:langbot/dify-agent/default." +related_cases: + - dify-agent-debug-chat + - local-agent-rag-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/backend-not-listening.yaml b/skills/skills/langbot-testing/troubleshooting/backend-not-listening.yaml new file mode 100644 index 000000000..37df32df0 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/backend-not-listening.yaml @@ -0,0 +1,29 @@ +id: backend-not-listening +title: "LangBot backend URL has no listening service" +date: 2026-05-30 +symptoms: + - "The frontend URL opens, but backend-dependent WebUI cases are blocked." + - "env doctor fails only for LANGBOT_BACKEND_URL." + - "curl to the backend port fails before receiving an HTTP status." +patterns: + - "LANGBOT_BACKEND_URL" + - "no HTTP service reachable" + - "Failed to connect to 127.0.0.1 port 5300" + - "TypeError: fetch failed" + - "Couldn't connect to server" +likely_causes: + - "The LangBot backend process is not running." + - "The frontend dev server is running by itself, so the browser opens but API calls cannot work." + - "The backend was started in a disposable foreground session and stopped when that session ended." + - "A detached startup command exited early and no process remains bound to the configured port." +fix_steps: + - "Run bin/lbs env doctor and confirm whether LANGBOT_BACKEND_URL reports no listener." + - "Start the backend from LANGBOT_REPO with uv run main.py." + - "Wait for Running on http://0.0.0.0:5300 and Connected to plugin runtime." + - "Verify ss -ltnp shows the configured backend port." + - "Re-run bin/lbs env doctor before starting browser QA." +verification: "LANGBOT_BACKEND_URL returns an HTTP status, env doctor passes the backend check, and backend logs show Connected to plugin runtime." +related_cases: + - pipeline-debug-chat + - webui-login-state + - local-agent-basic-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/box-session-conflict-logical-metadata.yaml b/skills/skills/langbot-testing/troubleshooting/box-session-conflict-logical-metadata.yaml new file mode 100644 index 000000000..7443fda1f --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/box-session-conflict-logical-metadata.yaml @@ -0,0 +1,23 @@ +id: box-session-conflict-logical-metadata +title: "BoxSessionConflictError after a successful first exec" +date: 2026-05-18 +symptoms: + - "The first sandbox exec succeeds, but the next exec in the same Debug Chat fails." + - "The model repeatedly retries commands and eventually reports a session conflict." + - "Skill registration or activation cannot continue after the first command." +patterns: + - "BoxSessionConflictError" + - "already exists with image=host" + - "already exists with mount_path=/home/user/workspace" +likely_causes: + - "Backend session metadata stored provider-specific values instead of LangBot logical spec values." + - "E2B stored adapted /home/user/workspace as mount_path, while later specs still use /workspace." + - "nsjail stored image=host, while later specs still use the configured logical sandbox image." +fix_steps: + - "Store logical spec.mount_path in BoxSessionInfo for E2B session metadata." + - "Store spec.image in nsjail BoxSessionInfo even though nsjail executes against host-mounted system paths." + - "Keep provider-specific path rewriting inside the backend command execution layer." + - "Restart LangBot to clear stale in-memory sessions after applying the fix." +verification: "Run two or more exec calls in the same Debug Chat session. The second call should reuse the session instead of raising BoxSessionConflictError." +related_cases: + - sandbox-skill-authoring-e2e diff --git a/skills/skills/langbot-testing/troubleshooting/debug-chat-history-contaminates-automation.yaml b/skills/skills/langbot-testing/troubleshooting/debug-chat-history-contaminates-automation.yaml new file mode 100644 index 000000000..3b0156c94 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/debug-chat-history-contaminates-automation.yaml @@ -0,0 +1,25 @@ +id: debug-chat-history-contaminates-automation +title: "Old Debug Chat messages contaminate automation assertions" +date: 2026-05-21 +symptoms: + - "A browser automation run reports Agent runner temporarily unavailable even though the latest bot response contains the expected text." + - "The screenshot shows an old failure bubble above the latest successful exchange." + - "The automation scans the whole document body instead of comparing only new messages or new failure counts." +patterns: + - "Agent runner temporarily unavailable" + - "Expected text appeared enough times" + - "Debug Chat displayed a known failure signal" +likely_causes: + - "Debug Chat history persists across runs and old failed messages remain visible." + - "The automation checks failure text anywhere in document.body after the run." + - "The automation does not compare failure counts before and after sending the prompt." +fix_steps: + - "Before sending the prompt, record counts for expected text and known failure text." + - "After sending the prompt, require the expected text count to increase enough for the user prompt plus bot response." + - "Treat known failure text as a new failure only if its count increased during this run." + - "For manual review, inspect the newest user/bot message pair rather than the whole chat transcript." +verification: "A latest successful bot response is not failed by old failure bubbles already present in the Debug Chat history." +related_cases: + - pipeline-debug-chat + - local-agent-plugin-tool-call-debug-chat + - mcp-stdio-tool-call diff --git a/skills/skills/langbot-testing/troubleshooting/dynamic-form-missing-config-id.yaml b/skills/skills/langbot-testing/troubleshooting/dynamic-form-missing-config-id.yaml new file mode 100644 index 000000000..6504b9a20 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/dynamic-form-missing-config-id.yaml @@ -0,0 +1,21 @@ +id: dynamic-form-missing-config-id +title: "Dynamic form fields have no stable key" +date: 2026-05-17 +symptoms: + - "Browser console shows a React warning about children in a list needing a unique key." + - "The warning appears when rendering plugin or runner dynamic form schemas." +patterns: + - "Each child in a list should have a unique key prop" + - "DynamicFormComponent" + - "config schema" +likely_causes: + - "A plugin component schema item has name but no id." + - "The frontend maps dynamic config items using config.id as the only key." +fix_steps: + - "Prefer adding stable ids to backend metadata derived from component id and field name." + - "Keep a frontend fallback key using config.id, config.name, then list index for defensive rendering." + - "Reload the affected form and inspect console warnings." +verification: "The dynamic form renders runner/plugin fields without React unique key warnings." +related_cases: + - langrag-kb-retrieve + - local-agent-rag-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/e2b-extra-mount-sync-missing.yaml b/skills/skills/langbot-testing/troubleshooting/e2b-extra-mount-sync-missing.yaml new file mode 100644 index 000000000..c739750c3 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/e2b-extra-mount-sync-missing.yaml @@ -0,0 +1,23 @@ +id: e2b-extra-mount-sync-missing +title: "Activated skill files are missing or not written back on E2B" +date: 2026-05-18 +symptoms: + - "register_skill succeeds but the activated skill cannot be used from /workspace/.skills/." + - "A file written inside /workspace/.skills/ does not appear in the Box-managed skill store." + - "The same activate-and-exec flow works on Docker or nsjail but fails on E2B." +patterns: + - "No such file or directory: /workspace/.skills" + - "Skill file not found after activated skill exec" + - "E2B command runs under /home/user/workspace while LangBot paths use /workspace" +likely_causes: + - "Public E2B does not provide host bind mounts, so LangBot must upload and download mounted directories." + - "Only the main workspace mount was synced, while activated skills are passed as extra_mounts." + - "Writeback from writable extra_mounts was not downloaded after command completion." +fix_steps: + - "Sync the main host_path and each extra_mount into the adapted E2B path before command execution." + - "After command execution, sync writable host_path and writable extra_mounts back to host storage." + - "Rewrite logical /workspace command paths to E2B's internal writable path only at execution time." + - "Keep session metadata mount_path as LangBot's logical path, not the E2B-internal path." +verification: "Run sandbox-skill-authoring-e2e with BOX_BACKEND=e2b and confirm activated_writeback.txt is readable from the registered skill package after the second exec." +related_cases: + - sandbox-skill-authoring-e2e diff --git a/skills/skills/langbot-testing/troubleshooting/local-agent-model-route-unavailable.yaml b/skills/skills/langbot-testing/troubleshooting/local-agent-model-route-unavailable.yaml new file mode 100644 index 000000000..779654492 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/local-agent-model-route-unavailable.yaml @@ -0,0 +1,35 @@ +id: local-agent-model-route-unavailable +title: "Local Agent model route is unavailable for the requested run shape" +category: env_issue +date: 2026-05-21 +symptoms: + - "Debug Chat shows Agent runner temporarily unavailable after the user message is sent." + - "Basic streaming prompts may work, but tool-call, non-streaming, or multimodal prompts fail with the same runner and pipeline." + - "The failure happens after the local-agent runner starts, not during plugin discovery." +patterns: + - "runner.llm_error" + - "runner.tool_loop_error" + - "model_not_found" + - "no available channel for model" + - "invalid api key" + - "当前分组上游负载已饱和" + - "All models failed during streaming setup" +likely_causes: + - "The selected model route is unavailable in the current LangBot Space or upstream group." + - "The selected model works for plain chat but is not available for tool-call, multimodal, or non-streaming request shapes." + - "The provider credential or quota is invalid for the non-streaming path." + - "The selected model has function-call or vision metadata, but the upstream distributor cannot currently serve that model." +fix_steps: + - "First rerun a basic local-agent Debug Chat prompt on the same pipeline to confirm the runner host path still works." + - "Switch to a known-good model for the exact request shape being tested: plain chat, tool call, multimodal, or non-streaming." + - "When tool-call cases fail, retest with a model that is known to support function calling in the active environment." + - "When multimodal cases fail, retest with a model route that is known to accept image content." + - "When non-streaming fails with invalid api key, verify the provider credential used by the non-streaming requester path." + - "Do not classify this as an MCP, RAG, or local-agent runner implementation failure until the same model route works for the requested request shape outside the failing case." +verification: "The same case produces the expected bot-visible sentinel or tool result, and backend logs show request completion without runner.llm_error, runner.tool_loop_error, or All models failed." +related_cases: + - local-agent-basic-debug-chat + - local-agent-plugin-tool-call-debug-chat + - mcp-stdio-tool-call + - local-agent-multimodal-debug-chat + - local-agent-nonstreaming-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/marketplace-network-flaky.yaml b/skills/skills/langbot-testing/troubleshooting/marketplace-network-flaky.yaml new file mode 100644 index 000000000..649368613 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/marketplace-network-flaky.yaml @@ -0,0 +1,20 @@ +id: marketplace-network-flaky +title: "Marketplace requests are flaky but plugin data may still load" +date: 2026-05-17 +symptoms: + - "Marketplace page shows failed plugin list toasts or console network errors." + - "Some plugin cards still render and can be installed." +patterns: + - "Failed to get plugin list" + - "ERR_NETWORK_CHANGED" + - "https://space.langbot.app/api/v1/marketplace" +likely_causes: + - "Transient network/proxy instability while loading marketplace search, tags, icons, releases, or recommendation lists." + - "Non-critical marketplace resource requests fail after the main plugin list has already rendered." +fix_steps: + - "Retry the Marketplace page before treating the flow as blocked." + - "If the target plugin card is visible, continue through the UI install flow." + - "Use /api/v1/plugins and /api/v1/knowledge/engines only to confirm install result." +verification: "The target plugin appears in Installed Plugins or the relevant component endpoint, such as /api/v1/knowledge/engines for LangRAG." +related_cases: + - langrag-kb-retrieve diff --git a/skills/skills/langbot-testing/troubleshooting/mcp-stdio-args-not-applied.yaml b/skills/skills/langbot-testing/troubleshooting/mcp-stdio-args-not-applied.yaml new file mode 100644 index 000000000..c079d70e5 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/mcp-stdio-args-not-applied.yaml @@ -0,0 +1,36 @@ +id: mcp-stdio-args-not-applied +title: "MCP Stdio test runs only the command without arguments" +date: 2026-05-17 +symptoms: + - "The MCP server Test button fails even though the same command works in a terminal." + - "Backend logs show uv help text or another bare command output instead of starting the MCP server." + - "Backend logs show uv: not found after the fixture path is accepted by Box." + - "Backend logs show host_path is outside allowed_mount_roots before the stdio process starts." + - "The task exception is Connection failed, please check URL or Connection closed." + - "Backend startup logs say qa_mcp_echo_server.py cannot be opened from the LangBot repo root." +patterns: + - "An extremely fast Python package manager." + - "Usage: uv [OPTIONS] " + - "/bin/sh: 1: uv: not found" + - "host_path is outside allowed_mount_roots" + - "McpError: Connection closed" + - "mcp-test-_" + - "can't open file '.../LangBot/qa_mcp_echo_server.py'" +likely_causes: + - "Box refuses to mount the bundled fixture directory because it is not listed in box.local.allowed_mount_roots." + - "The saved MCP server command uses uv, but uv is not available inside the Box runtime PATH." + - "The WebUI did not pass the current stdio args to the test request." + - "The MCP form exposed a stale imperative test handler from an old render." + - "The user entered the whole command line into the command field instead of splitting command and args." + - "An old qa-local-stdio database record still points at a temporary LangBot root script instead of the bundled skills fixture." +fix_steps: + - "Confirm the fixture directory is listed in box.local.allowed_mount_roots for the local LangBot data config." + - "Confirm command is only the executable, normally python for this fixture." + - "Confirm args are separate entries; for this fixture use the server script path as the single arg." + - "Confirm the fixture script is the current dependency-free qa_mcp_echo_server.py." + - "For qa-local-stdio, set the server script path to the absolute skills fixture path: skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py." + - "If the UI still sends empty args, fix the MCP form test handler so it reads the latest state." + - "Retest with the bundled qa_mcp_echo_server.py fixture." +verification: "The MCP test task completes without exception, the saved server shows connected, and /api/v1/tools contains qa_mcp_echo." +related_cases: + - mcp-stdio-tool-call diff --git a/skills/skills/langbot-testing/troubleshooting/nsjail-cli-compatibility.yaml b/skills/skills/langbot-testing/troubleshooting/nsjail-cli-compatibility.yaml new file mode 100644 index 000000000..97f752cef --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/nsjail-cli-compatibility.yaml @@ -0,0 +1,29 @@ +id: nsjail-cli-compatibility +title: "Installed nsjail CLI rejects generated sandbox arguments" +date: 2026-05-18 +symptoms: + - "Box status reports nsjail as available, but every command exits before the user command runs." + - "Direct nsjail --help works, but LangBot exec fails." + - "On WSL, cgroup v2 warnings appear before falling back to rlimits." +patterns: + - "nsjail: unrecognized option '--clone_newuser'" + - "nsjail: unrecognized option '--clone_newnet'" + - "nsjail: unrecognized option '--rw_bind'" + - "execve('sh') failed: No such file or directory" + - "Failed to mount mandatory point: '/workspace'" + - "createCgroup() ... failed" +likely_causes: + - "The installed nsjail version enables clone namespaces by default and exposes only disable flags." + - "The installed nsjail uses --bindmount for read-write bind mounts, not --rw_bind." + - "A bare chroot at / cannot create mount targets for /workspace." + - "The command uses sh instead of /bin/sh inside the chroot." + - "cgroup v2 exists but is not writable by the LangBot user." +fix_steps: + - "Generate no positive --clone_new* flags; use --disable_clone_newnet only when network is requested." + - "Use --bindmount for read-write mounts and --bindmount_ro for read-only mounts." + - "Create a per-session chroot root and pre-create mount target directories such as /workspace, /tmp, /home, and activated skill paths." + - "Execute commands through /bin/sh -lc." + - "Check cgroup v2 writability before using cgroup flags; warn and use rlimits when not writable." +verification: "Run sandbox-skill-authoring-e2e with BOX_BACKEND=nsjail. The model should complete exec, register_skill, activate, and activated skill exec without nsjail argument errors." +related_cases: + - sandbox-skill-authoring-e2e diff --git a/skills/skills/langbot-testing/troubleshooting/pipeline-form-controlled-warning.yaml b/skills/skills/langbot-testing/troubleshooting/pipeline-form-controlled-warning.yaml new file mode 100644 index 000000000..92f788a87 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/pipeline-form-controlled-warning.yaml @@ -0,0 +1,20 @@ +id: pipeline-form-controlled-warning +title: "Pipeline form input switches from uncontrolled to controlled" +date: 2026-05-17 +symptoms: + - "Browser console shows a React uncontrolled input to controlled input warning." + - "The warning appears after opening a pipeline edit page or loading pipeline data." +patterns: + - "A component is changing an uncontrolled input to be controlled" + - "PipelineFormComponent" +likely_causes: + - "React Hook Form default values omit string fields that later receive loaded values." + - "Input value receives undefined before the backend response arrives." +fix_steps: + - "Initialize pipeline basic fields such as name and description to empty strings." + - "When loading backend data, coalesce nullable string values to empty strings." + - "Pass value={field.value ?? ''} for text inputs that can render before data loads." +verification: "Opening the pipeline edit page and AI config produces no uncontrolled/controlled input warning." +related_cases: + - pipeline-debug-chat + - local-agent-rag-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/plugin-dependency-install-offline.yaml b/skills/skills/langbot-testing/troubleshooting/plugin-dependency-install-offline.yaml new file mode 100644 index 000000000..f5a484df8 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/plugin-dependency-install-offline.yaml @@ -0,0 +1,24 @@ +id: plugin-dependency-install-offline +title: "Local plugin dependency install fails in an offline or proxy-limited runtime" +date: 2026-05-17 +symptoms: + - "Installing a local .lbpkg starts but fails during Installing Dependencies." + - "The plugin files may appear under data/plugins, but the plugin is not initialized or listed as active." +patterns: + - "Failed to install dependency: PyMuPDF>=1.24.0" + - "ERROR: Could not find a version that satisfies the requirement PyMuPDF" + - "ERROR: No matching distribution found for PyMuPDF" + - "Installing langbot-team-GeneralParsers" +likely_causes: + - "The LangBot plugin runtime is started without a working network path to PyPI or the configured package index." + - "The backend was started with proxy variables unset to avoid SOCKS-related HTTP client errors, so plugin dependency installation cannot download uncached packages." + - "The dependency is available in another local venv or cache, but not in the active LangBot master venv used by the plugin runtime." +fix_steps: + - "Confirm the active runtime venv: use the same LangBot master .venv that starts the backend." + - "Before retrying local plugin install, verify required dependencies with python -m pip install --dry-run ''." + - "If the environment is offline, install from an existing local wheel/cache or another trusted local venv into the active LangBot master venv." + - "Retry the UI Upload Local install and wait for Plugin installed successfully." + - "For GeneralParsers, confirm import fitz works in the active LangBot master venv before retrying." +verification: "Installed Plugins shows the local plugin initialized, and backend logs include Plugin langbot-team/GeneralParsers initialized." +related_cases: + - langrag-parser-golden-e2e diff --git a/skills/skills/langbot-testing/troubleshooting/plugin-runtime-timeout.yaml b/skills/skills/langbot-testing/troubleshooting/plugin-runtime-timeout.yaml new file mode 100644 index 000000000..3b0f33f36 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/plugin-runtime-timeout.yaml @@ -0,0 +1,31 @@ +id: plugin-runtime-timeout +title: "Plugin runtime actions time out" +date: 2026-05-16 +symptoms: + - "Debug Chat can send a user message, but no useful Bot response appears." + - "The page may show Agent runner temporarily unavailable." + - "Installed Plugins may show Plugin System Connection Error." + - "Knowledge sidebar or plugin sidebar loading may hang or time out." +patterns: + - "Action list_plugins call timed out" + - "Action list_agent_runners call timed out" + - "Action invoke_llm_stream call timed out" + - "All models failed during streaming setup" + - "Failed to fetch plugins for sidebar" + - "Failed to fetch knowledge bases for sidebar" +likely_causes: + - "An old langbot_plugin runtime process survived a backend restart." + - "Multiple plugin runtime processes are active and the backend is waiting on the wrong one." + - "Plugin runtime got a control connection but never finished plugin discovery or launch." + - "A disposable test plugin in data/plugins is blocking runtime startup." +fix_steps: + - "Stop the LangBot backend." + - "Stop orphaned langbot_plugin.cli runtime processes." + - "Confirm the configured backend URL is free or reachable as appropriate." + - "Start LangBot again and wait for Connected to plugin runtime plus langbot/local-agent initialization." + - "For disposable test data only, move suspected fixture plugins out of data/plugins and restart to isolate plugin discovery failures." +verification: "Open Installed Plugins and confirm the plugin list loads, then run pipeline-debug-chat and confirm the UI shows a Bot response while backend logs include Streaming completed." +related_cases: + - pipeline-debug-chat + - provider-deepseek + - langrag-parser-golden-e2e diff --git a/skills/skills/langbot-testing/troubleshooting/provider-image-parse-error.yaml b/skills/skills/langbot-testing/troubleshooting/provider-image-parse-error.yaml new file mode 100644 index 000000000..4ddbcfef7 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/provider-image-parse-error.yaml @@ -0,0 +1,23 @@ +id: provider-image-parse-error +title: "Model provider rejects the uploaded image before runner behavior is exercised" +date: 2026-05-17 +symptoms: + - "Debug Chat shows Agent runner temporarily unavailable after an image upload." + - "Backend logs include image_parse_error or unsupported image from the model provider." + - "The upload endpoint succeeds, but the LLM request fails before a useful model response is returned." +patterns: + - "image_parse_error" + - "unsupported image" + - "You uploaded an unsupported image" + - "runner.llm_error" +likely_causes: + - "The fixture image is too small or otherwise rejected by the active model provider." + - "The selected model or provider route does not support image input even though the WebUI upload succeeded." + - "The provider reports image validation errors as rate-limit or requester errors." +fix_steps: + - "Retest with a normal-sized PNG such as the bundled 64x64 red-square fixture." + - "Switch to a model route known to accept image input before diagnosing local-agent multimodal handling." + - "Use backend logs to separate provider image parsing failure from runner context or message-building failure." +verification: "The same Debug Chat image prompt reaches the model without image_parse_error; if semantic vision is unreliable, treat UI image visibility plus message-building tests as the runner coverage signal." +related_cases: + - local-agent-multimodal-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/proxy-env-mismatch.yaml b/skills/skills/langbot-testing/troubleshooting/proxy-env-mismatch.yaml new file mode 100644 index 000000000..b9b398edd --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/proxy-env-mismatch.yaml @@ -0,0 +1,22 @@ +id: proxy-env-mismatch +title: "Uppercase and lowercase proxy variables differ" +date: 2026-05-16 +symptoms: + - "External model calls time out even though a proxy appears to be configured." + - "GitHub OAuth or provider tests fail intermittently from the agent environment." +patterns: + - "HTTP_PROXY and http_proxy point to different hosts" + - "HTTPS_PROXY and https_proxy point to different hosts" + - "ALL_PROXY and all_proxy point to different hosts" + - "old WSL gateway proxy remains in lowercase variables" +likely_causes: + - "Different HTTP clients read different proxy environment variable names." + - "The shell inherited stale lowercase proxy values from an older session." +fix_steps: + - "Set HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, http_proxy, https_proxy, all_proxy, NO_PROXY, and no_proxy consistently." + - "Restart LangBot with the corrected proxy environment." +verification: "Run env | rg -i '^(http|https|all|no)_?proxy=' and confirm uppercase/lowercase pairs are consistent, then run pipeline-debug-chat." +related_cases: + - pipeline-debug-chat + - provider-deepseek + - webui-login-state diff --git a/skills/skills/langbot-testing/troubleshooting/sandbox-native-tools-unavailable.yaml b/skills/skills/langbot-testing/troubleshooting/sandbox-native-tools-unavailable.yaml new file mode 100644 index 000000000..6c6106036 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/sandbox-native-tools-unavailable.yaml @@ -0,0 +1,24 @@ +id: sandbox-native-tools-unavailable +title: "Native sandbox tools are unavailable even though a backend is configured" +date: 2026-05-18 +symptoms: + - "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available." + - "The Box runtime later reports that E2B, nsjail, or Docker is configured." + - "Debug Chat does not expose exec, register_skill, or activate as usable tools." +patterns: + - "Native sandbox tools ... are NOT available" + - "No sandbox backend (Docker/nsjail/E2B) is ready" + - "box.backend is set but /api/v1/box/status backend is unavailable" +likely_causes: + - "Backend status was checked before the Box runtime selected or reselected a backend." + - "The configured backend failed availability checks because credentials, binary, Docker daemon, or proxy settings were not ready." + - "The runtime cached an unavailable backend state after INIT config changed the backend." +fix_steps: + - "Check /api/v1/box/status and verify backend.name and backend.available." + - "Restart LangBot after fixing backend credentials, binary installation, Docker daemon, or proxy settings." + - "Ensure the Box runtime reselects a backend when get_backend_info is called and the cached backend is empty." + - "For E2B, verify the key without printing it and confirm any required template setting." + - "For nsjail, run nsjail --help and confirm the binary is on PATH for the LangBot process." +verification: "Run sandbox-skill-authoring-e2e. Logs should show Native sandbox tools are available and /api/v1/box/status should report available=true with the expected backend." +related_cases: + - sandbox-skill-authoring-e2e diff --git a/skills/skills/langbot-testing/troubleshooting/socks-proxy-without-socksio.yaml b/skills/skills/langbot-testing/troubleshooting/socks-proxy-without-socksio.yaml new file mode 100644 index 000000000..b3e6d7b93 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/socks-proxy-without-socksio.yaml @@ -0,0 +1,24 @@ +id: socks-proxy-without-socksio +title: "Python HTTP clients fail when ALL_PROXY uses SOCKS without socksio" +date: 2026-05-18 +symptoms: + - "LangBot startup fails while importing or initializing a provider client." + - "The failure happens before sandbox testing begins." + - "Clearing ALL_PROXY lets LangBot start, but external HTTP calls may then need another proxy path." +patterns: + - "Using SOCKS proxy, but the 'socksio' package is not installed" + - "httpx using SOCKS proxy" + - "ALL_PROXY=socks5://" +likely_causes: + - "The process inherited ALL_PROXY or all_proxy with a SOCKS URL." + - "A dependency uses httpx without the socks extra installed." + - "HTTP_PROXY and HTTPS_PROXY would have been sufficient, but ALL_PROXY took precedence for that client." +fix_steps: + - "Start LangBot with ALL_PROXY and all_proxy unset when SOCKS support is not installed." + - "Keep HTTP_PROXY and HTTPS_PROXY set consistently if an HTTP proxy is available." + - "Alternatively install the dependency's SOCKS support in the runtime environment." + - "Keep NO_PROXY/no_proxy covering localhost, 127.0.0.1, and ::1." +verification: "LangBot starts cleanly, model provider calls still use the intended proxy path, and sandbox-skill-authoring-e2e can reach the model provider." +related_cases: + - sandbox-skill-authoring-e2e + - provider-deepseek diff --git a/skills/skills/langbot-testing/troubleshooting/survey-widget-blocks-debug-chat.yaml b/skills/skills/langbot-testing/troubleshooting/survey-widget-blocks-debug-chat.yaml new file mode 100644 index 000000000..f70ce6334 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/survey-widget-blocks-debug-chat.yaml @@ -0,0 +1,22 @@ +id: survey-widget-blocks-debug-chat +title: "Survey widget blocks Debug Chat controls" +date: 2026-05-17 +symptoms: + - "Debug Chat input is filled but the Send button cannot be clicked." + - "Playwright reports that a fixed bottom-right widget intercepts pointer events." +patterns: + - "subtree intercepts pointer events" + - "Help us improve" + - "fixed bottom-6 right-6" +likely_causes: + - "The survey widget overlaps the Debug Chat action area at the current viewport size." + - "The test viewport is narrow enough that the floating widget covers the Send button." +fix_steps: + - "Close or minimize the survey widget before interacting with Debug Chat." + - "Alternatively, resize the browser wider or use keyboard submission if the UI supports it." + - "Continue the browser test after confirming the widget is gone." +verification: "The Send button can be clicked and Debug Chat shows both the User message and Bot response." +related_cases: + - pipeline-debug-chat + - local-agent-rag-debug-chat + - mcp-stdio-tool-call diff --git a/skills/skills/langbot-testing/troubleshooting/telemetry-proxy-noise.yaml b/skills/skills/langbot-testing/troubleshooting/telemetry-proxy-noise.yaml new file mode 100644 index 000000000..945109029 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/telemetry-proxy-noise.yaml @@ -0,0 +1,23 @@ +id: telemetry-proxy-noise +title: "Telemetry posting fails through the proxy while the target flow succeeds" +date: 2026-06-25 +category: env_issue +symptoms: + - "The target Debug Chat or provider smoke request completes successfully." + - "The same log window contains a Traceback for telemetry posting." + - "The traceback references the Space telemetry endpoint." +patterns: + - "Failed to post telemetry" + - "https://space.langbot.app/api/v1/telemetry" + - "httpx.ConnectError" +likely_causes: + - "The backend process inherited proxy settings that are required for model/provider access but unreliable for telemetry posting." + - "The telemetry endpoint is temporarily unreachable through the local proxy route." + - "TLS or proxy negotiation failed for the non-critical telemetry request." +fix_steps: + - "Keep the proxy configuration needed for model/provider access; do not clear it only to hide telemetry noise." + - "Check that uppercase and lowercase proxy variables are consistent before rerunning a live Space smoke." + - "Classify the target flow and log-health result separately: a successful Debug Chat run can still have an environment log-health finding." +verification: "A rerun shows the target case success patterns and no telemetry Traceback in the scanned log window, or the report explicitly records the telemetry issue as environment noise." +related_cases: + - langbot-space-debug-chat-concurrency-smoke diff --git a/skills/skills/langbot-testing/troubleshooting/tool-name-collision-between-mcp-and-plugin.yaml b/skills/skills/langbot-testing/troubleshooting/tool-name-collision-between-mcp-and-plugin.yaml new file mode 100644 index 000000000..ab40b0406 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/tool-name-collision-between-mcp-and-plugin.yaml @@ -0,0 +1,25 @@ +id: tool-name-collision-between-mcp-and-plugin +title: "MCP and plugin expose the same tool name" +date: 2026-05-21 +symptoms: + - "An MCP tool-call case completes, but the returned sentinel has a plugin prefix instead of the MCP prefix." + - "The prompt asks for the MCP fixture tool, but the bot returns qa-plugin-smoke:." + - "GET /api/v1/tools or the runner tool list contains multiple tools with the same visible name." +patterns: + - "qa-plugin-smoke:mcp-ok-local-agent" + - "multiple tools with the same visible name" + - "duplicate tool name" + - "qa/plugin-smoke" +likely_causes: + - "A stale MCP registration still exposes the old qa_echo fixture name while qa-plugin-smoke is installed." + - "The model selects the plugin tool because both tools have the same call name or similar descriptions." + - "The test prompt names an ambiguous tool, so the browser result cannot prove which provider executed the tool unless the returned sentinel is checked." +fix_steps: + - "Use unique tool names across simultaneous test fixtures. The current MCP fixture should expose qa_mcp_echo, not qa_echo." + - "When testing MCP, require the bot response to contain the MCP-specific sentinel qa_mcp_echo:, not qa-plugin-smoke:." + - "When testing plugin tools, prefer qa_plugin_echo or another plugin-only tool name that cannot be confused with the MCP fixture." + - "Refresh the MCP server registration or restart the backend if /api/v1/tools still shows the old qa_echo MCP fixture." +verification: "The MCP case passes only when the bot-visible response contains the MCP fixture sentinel and the plugin-only sentinel is absent for the same input." +related_cases: + - mcp-stdio-tool-call + - local-agent-plugin-tool-call-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/uv-run-resyncs-local-sdk.yaml b/skills/skills/langbot-testing/troubleshooting/uv-run-resyncs-local-sdk.yaml new file mode 100644 index 000000000..e05a2f04b --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/uv-run-resyncs-local-sdk.yaml @@ -0,0 +1,22 @@ +id: uv-run-resyncs-local-sdk +title: "uv run resyncs the locked SDK instead of the local editable SDK" +date: 2026-05-17 +symptoms: + - "LangBot was expected to use a local langbot-plugin SDK checkout, but import inspection points to .venv/site-packages." + - "uv output shows langbot-plugin was uninstalled and installed again immediately before a test or startup command." + - "A test appears to pass but is actually using the PyPI SDK from uv.lock." +patterns: + - "uv run python -c import langbot_plugin prints .venv/lib/python*/site-packages/langbot_plugin" + - "Uninstalled 1 package" + - "Installed 1 package" + - "langbot-plugin==0.3.11 without file:/// local source" +likely_causes: + - "Plain uv run synchronizes the environment to uv.lock before executing the command." + - "The editable local SDK was installed with uv pip install -e, but the next uv run reverted it." +fix_steps: + - "Install the local SDK into the LangBot worktree with uv pip install -e /absolute/path/to/langbot-plugin-sdk-test-build." + - "Run validation and startup commands with uv run --no-sync." + - "Before trusting results, print pathlib.Path(langbot_plugin.__file__).resolve() and confirm it points to the local SDK source tree." +verification: "uv run --no-sync python -c import inspection prints the local SDK source path, and plugin-e2e-smoke passes with that same process environment." +related_cases: + - plugin-e2e-smoke diff --git a/skills/src/cli.ts b/skills/src/cli.ts new file mode 100644 index 000000000..27a59cf9e --- /dev/null +++ b/skills/src/cli.ts @@ -0,0 +1,120 @@ +import { dirname, resolve } from "node:path"; +import { cwd, exit } from "node:process"; +import { existsSync } from "node:fs"; +import type { CommandContext } from "./types.ts"; + +export function usage(): never { + console.log(`Usage: + bin/lbs [--root ] list + bin/lbs [--root ] validate + bin/lbs [--root ] index [--check] + bin/lbs [--root ] new-skill [--description ] + bin/lbs [--root ] new-ref + + bin/lbs [--root ] env show [--json] + bin/lbs [--root ] env doctor + + bin/lbs [--root ] fixture list [skill] [--json] + bin/lbs [--root ] fixture check [skill] [--json] + + bin/lbs [--root ] log scan [--json] [--output ] [--backend-log ] [--frontend-log ] [--console-log ] [--case ] [--success-pattern ] [--failure-pattern ] [--expected-failure ] [--since ] [--until ] [--tail-lines ] [--no-auto-log] [--strict] + bin/lbs [--root ] log watch [--json] [--backend-log ] [--case ] [--success-pattern ] [--failure-pattern ] [--expected-failure ] [--interval-ms ] [--duration-ms ] [--from-start] [--strict] + bin/lbs [--root ] log guard start [--run-id ] [--output-dir ] [--backend-log ] [--case ] [--json] + bin/lbs [--root ] log guard stop --run-id [--output-dir ] [--session ] [--output ] [--case ] [--backend-log ] [--since ] [--until ] [--json] [--no-strict] + + bin/lbs [--root ] case new --title [--skill langbot-testing] [--mode agent-browser|probe] [--area ] [--type smoke] + bin/lbs [--root ] case list [skill] [--json] [--type ] [--area ] [--tag ] [--priority p0|p1|p2] [--risk low|medium|high] [--automation] [--ci] [--ready] [--machine-ready] + bin/lbs [--root ] case show [skill] + + bin/lbs [--root ] suite new --title [--skill langbot-testing] [--description ] [--type smoke] [--priority p2] + bin/lbs [--root ] suite list [skill] [--json] [--type ] [--priority p0|p1|p2] + bin/lbs [--root ] suite show [skill] + bin/lbs [--root ] suite plan [skill] [--json] + bin/lbs [--root ] suite start [skill] [--run-id ] [--evidence-dir ] [--output ] [--json] + bin/lbs [--root ] suite run [skill] [--run-id ] [--evidence-dir ] [--output ] [--headed] [--dry-run] [--include-manual-check] [--include-not-ready] [--json] + bin/lbs [--root ] suite report [skill] [--run-id ] [--evidence-dir ] [--output ] [--json] + + bin/lbs [--root ] test plan [skill] [--json] + bin/lbs [--root ] test recommend [--file ] [--json] + bin/lbs [--root ] test start [skill] [--output ] [--json] + bin/lbs [--root ] test run [skill] [--output ] [--run-id ] [--headed] [--dry-run] [--json] + bin/lbs [--root ] test report [skill] [--output ] [--json] [--backend-log ] [--frontend-log ] [--console-log ] [--evidence-dir ] [--since ] [--until ] [--tail-lines ] [--no-auto-log] + bin/lbs [--root ] test result [skill] --result --reason --evidence-dir [--evidence ui,console,backend_log] [--started-at ] [--finished-at ] [--run-id ] [--url ] [--browser-path ] [--report ] [--notes ] [--json] + + bin/lbs [--root ] trouble list [skill] + bin/lbs [--root ] trouble show [skill] + bin/lbs [--root ] trouble search + bin/lbs [--root ] trouble add --title --symptom --cause --fix [--id ] [--verify ] +`); + exit(2); +} + +export function fail(message: string): never { + console.error(`ERROR: ${message}`); + exit(1); +} + +export function repoRoot(start: string): string { + let current = resolve(start); + while (true) { + // The skills assets root is identified by skills.index.json (present at the + // root of this assets tree). Check it first so that when the tree lives + // inside a larger repo (e.g. LangBot/skills/), we stop at the assets root + // and not at the outer repo's .git/README.md. + if (existsSync(`${current}/skills.index.json`) && existsSync(`${current}/schemas/case.schema.json`)) { + return current; + } + if (existsSync(`${current}/.git`) && existsSync(`${current}/README.md`)) { + return current; + } + const parent = dirname(current); + if (parent === current) return resolve(start); + current = parent; + } +} + +export function parseGlobalArgs(rawArgs: string[]): CommandContext { + let root = repoRoot(cwd()); + const args = [...rawArgs]; + + for (let i = 0; i < args.length; ) { + if (args[i] === "--root") { + const value = args[i + 1]; + if (!value) fail("--root requires a path"); + root = resolve(value); + args.splice(i, 2); + continue; + } + i += 1; + } + + return { root, args }; +} + +export function parseOptions(args: string[]): { positional: string[]; options: Record } { + const positional: string[] = []; + const options: Record = {}; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg.startsWith("--")) { + const key = arg.slice(2); + const value = args[i + 1]; + if (!value || value.startsWith("--")) { + options[key] = true; + } else { + options[key] = value; + i += 1; + } + } else { + positional.push(arg); + } + } + + return { positional, options }; +} + +export function optionString(options: Record, key: string): string | undefined { + const value = options[key]; + return typeof value === "string" ? value : undefined; +} diff --git a/skills/src/commands/case.ts b/skills/src/commands/case.ts new file mode 100644 index 000000000..eeec83728 --- /dev/null +++ b/skills/src/commands/case.ts @@ -0,0 +1,180 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import type { CommandContext } from "../types.ts"; +import { parseOptions, optionString, usage, fail } from "../cli.ts"; +import { caseModeValues } from "../constants.ts"; +import { boolValue, findStructuredItem, getSkill, listValue, loadStructuredItems, scalar, yamlList, yamlQuote } from "../fs.ts"; +import { caseAutomationReadiness, caseEnvReadiness, caseFixtureReadiness, caseManualReadiness, runtimeEnv } from "../readiness.ts"; +import { setupAutomationEntries } from "../setup-automation.ts"; + +function casePath(root: string, skillName: string, id: string): string { + const skill = getSkill(root, skillName); + const dir = join(skill.path, "cases"); + mkdirSync(dir, { recursive: true }); + return join(dir, `${id}.yaml`); +} + +export function commandCaseNew(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(2)); + const id = positional[0]; + const title = optionString(options, "title"); + if (!id || !title) usage(); + + const skill = optionString(options, "skill") ?? "langbot-testing"; + const path = casePath(ctx.root, skill, id); + if (existsSync(path)) fail(`case already exists: ${path}`); + + const area = optionString(options, "area") ?? "general"; + const type = optionString(options, "type") ?? "smoke"; + const mode = optionString(options, "mode") ?? "agent-browser"; + if (!caseModeValues.includes(mode)) fail(`--mode must be one of ${caseModeValues.join(", ")}`); + const isProbe = mode === "probe"; + + const text = + `id: ${id}\n` + + `title: ${yamlQuote(title)}\n` + + `mode: ${mode}\n` + + `area: ${area}\n` + + `type: ${type}\n` + + "priority: p2\n" + + "risk: medium\n" + + "ci_eligible: false\n" + + "tags:\n" + + yamlList([type]) + + "\nskills:\n" + + yamlList(["langbot-env-setup", skill]) + + "\nenv:\n" + + yamlList(isProbe ? [] : ["LANGBOT_FRONTEND_URL", "LANGBOT_BACKEND_URL"]) + + "\nsteps:\n" + + yamlList([isProbe ? "Describe the probe command, script, or diagnostic to run." : "Describe the user-visible action to perform."]) + + "\nchecks:\n" + + yamlList(isProbe + ? [ + "Probe: Describe the expected success signal.", + "Evidence: Required logs, API diagnostics, or filesystem artifacts are written.", + ] + : [ + "UI: Describe the user-visible success signal.", + "Console: No unexpected frontend errors.", + "Logs: Relevant backend processing completed when applicable.", + ]) + + "\nevidence_required:\n" + + yamlList(isProbe ? ["api_diagnostic"] : ["ui", "console"]) + + "\ndiagnostics:\n" + + yamlList([isProbe + ? "Use logs, API, or filesystem diagnostics to explain probe failures." + : "Use API/curl/logs only to distinguish frontend failure from backend/runtime failure."]) + + "\ntroubleshooting:\n" + + yamlList([]) + + "\n"; + + writeFileSync(path, text, "utf8"); + console.log(path); + return 0; +} + +function caseRow(item: ReturnType[number], root: string): Record { + const automation = scalar(item.fields, "automation"); + const env = runtimeEnv(root); + const id = scalar(item.fields, "id"); + const row = { + skill: item.skill, + id, + title: scalar(item.fields, "title"), + mode: scalar(item.fields, "mode"), + area: scalar(item.fields, "area"), + type: scalar(item.fields, "type"), + priority: scalar(item.fields, "priority"), + risk: scalar(item.fields, "risk"), + ci_eligible: boolValue(item.fields, "ci_eligible") ?? false, + tags: listValue(item.fields, "tags"), + env: listValue(item.fields, "env"), + env_any: listValue(item.fields, "env_any"), + preconditions: listValue(item.fields, "preconditions"), + setup: listValue(item.fields, "setup"), + setup_automation: setupAutomationEntries(item), + setup_provides_env: listValue(item.fields, "setup_provides_env"), + cleanup: listValue(item.fields, "cleanup"), + evidence_required: listValue(item.fields, "evidence_required"), + automation, + automation_exists: automation ? existsSync(resolve(root, automation)) : false, + env_readiness: caseEnvReadiness(item, env), + automation_readiness: caseAutomationReadiness(item, env), + fixture_readiness: caseFixtureReadiness(root, id), + manual_readiness: caseManualReadiness(item), + }; + return { + ...row, + readiness: readinessLabel(row), + }; +} + +function hasTag(row: Record, tag: string): boolean { + const tags = row.tags; + return Array.isArray(tags) && tags.includes(tag); +} + +function hasMissingReadiness(row: Record): boolean { + for (const key of ["env_readiness", "automation_readiness", "fixture_readiness"]) { + const value = row[key] as Record | undefined; + if (value?.status === "missing") return true; + } + return false; +} + +function hasManualCheck(row: Record): boolean { + const manual = row.manual_readiness as Record | undefined; + return manual?.status === "manual_check"; +} + +function readinessLabel(row: Record): string { + if (hasMissingReadiness(row)) return "not-ready"; + return hasManualCheck(row) ? "manual-check" : "ready"; +} + +export function commandCaseList(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(2)); + const skill = positional[0]; + const rows = loadStructuredItems(ctx.root, "cases", skill) + .map((item) => caseRow(item, ctx.root)) + .filter((row) => !optionString(options, "type") || row.type === optionString(options, "type")) + .filter((row) => !optionString(options, "area") || row.area === optionString(options, "area")) + .filter((row) => !optionString(options, "priority") || row.priority === optionString(options, "priority")) + .filter((row) => !optionString(options, "risk") || row.risk === optionString(options, "risk")) + .filter((row) => !optionString(options, "tag") || hasTag(row, optionString(options, "tag") ?? "")) + .filter((row) => options.automation !== true || Boolean(row.automation)) + .filter((row) => options.ci !== true || row.ci_eligible === true) + .filter((row) => options["machine-ready"] !== true || !hasMissingReadiness(row)) + .filter((row) => options.ready !== true || (!hasMissingReadiness(row) && !hasManualCheck(row))); + + if (options.json === true) { + console.log(JSON.stringify(rows, null, 2)); + return 0; + } + + for (const row of rows) { + console.log([ + row.skill, + row.id, + row.type, + row.area, + row.priority, + row.risk, + row.ci_eligible ? "ci" : "manual", + row.automation ? "automated" : "manual-path", + row.readiness, + row.title, + ].join("\t")); + } + return 0; +} + +export function commandCaseShow(ctx: CommandContext): number { + const positional = ctx.args.slice(2); + if (positional.length < 1 || positional.length > 2) usage(); + const item = positional.length === 1 + ? findStructuredItem(ctx.root, "cases", positional[0]) + : findStructuredItem(ctx.root, "cases", positional[0], positional[1]); + console.log(item.raw.trimEnd()); + return 0; +} diff --git a/skills/src/commands/env.ts b/skills/src/commands/env.ts new file mode 100644 index 000000000..76ef33aec --- /dev/null +++ b/skills/src/commands/env.ts @@ -0,0 +1,175 @@ +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { Socket } from "node:net"; +import { join } from "node:path"; +import type { CommandContext } from "../types.ts"; +import { parseOptions } from "../cli.ts"; +import { loadEnv } from "../fs.ts"; +import { requiredEnvKeys } from "../constants.ts"; +import { redactEnvValue } from "../readiness.ts"; + +export function commandEnvShow(ctx: CommandContext): number { + const { options } = parseOptions(ctx.args.slice(2)); + const env = loadEnv(ctx.root); + const outputEnv = Object.fromEntries( + Object.entries(env).map(([key, value]) => [key, redactEnvValue(key, value)]), + ); + if (options.json === true) { + console.log(JSON.stringify(outputEnv, null, 2)); + return 0; + } + for (const key of Object.keys(outputEnv).sort()) { + console.log(`${key}=${outputEnv[key]}`); + } + return 0; +} + +async function checkUrl(label: string, url: string): Promise<{ ok: boolean; message: string }> { + if (!url) return { ok: false, message: `${label}: missing` }; + const displayUrl = redactEnvValue(label, url); + try { + const response = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(2500) }); + return { ok: response.ok || response.status < 500, message: `${label}: ${displayUrl} -> HTTP ${response.status}` }; + } catch (error) { + return { ok: false, message: `${label}: ${displayUrl} -> ${String(error).replace(/\s+/g, " ")}` }; + } +} + +function endpoint(url: string): { host: string; port: number } | null { + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + const port = parsed.port ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80; + return { host: parsed.hostname, port }; + } catch { + return null; + } +} + +async function checkTcpListener(url: string): Promise<{ ok: boolean; message: string } | null> { + const target = endpoint(url); + if (!target) return null; + + return await new Promise((resolve) => { + const socket = new Socket(); + let settled = false; + const finish = (ok: boolean, detail: string) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve({ + ok, + message: `${target.host}:${target.port} ${detail}`, + }); + }; + + socket.setTimeout(1500); + socket.once("connect", () => finish(true, "is listening")); + socket.once("timeout", () => finish(false, "did not accept TCP connection before timeout")); + socket.once("error", (error) => finish(false, `is not listening (${error.message})`)); + socket.connect(target.port, target.host); + }); +} + +function startupHint(label: string, env: Record): string | null { + if (label === "LANGBOT_BACKEND_URL" && env.LANGBOT_REPO) { + return `start backend: cd ${env.LANGBOT_REPO} && uv run main.py`; + } + if (label === "LANGBOT_FRONTEND_URL" && env.LANGBOT_WEB_REPO) { + return `start frontend: cd ${env.LANGBOT_WEB_REPO} && pnpm dev`; + } + return null; +} + +function compareProxyPair(env: Record, upper: string, lower: string): string | null { + const upperValue = process.env[upper] ?? env[upper] ?? ""; + const lowerValue = process.env[lower] ?? env[lower] ?? ""; + if (upperValue && lowerValue && upperValue !== lowerValue) { + return `${upper}/${lower}: mismatch (${redactEnvValue(upper, upperValue)} vs ${redactEnvValue(lower, lowerValue)})`; + } + return null; +} + +function envValue(env: Record, key: string): string { + return process.env[key] ?? env[key] ?? ""; +} + +function activeSocksProxy(env: Record): { key: string; value: string } | null { + for (const key of ["ALL_PROXY", "all_proxy", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]) { + const value = envValue(env, key); + if (/^socks/i.test(value)) return { key, value }; + } + return null; +} + +function checkSocksio(env: Record): string | null { + const proxy = activeSocksProxy(env); + if (!proxy) return null; + + const repo = env.LANGBOT_REPO; + const python = repo ? join(repo, ".venv", "bin", "python") : ""; + if (!python || !existsSync(python)) { + return `SOCKS proxy ${proxy.key} is configured (${redactEnvValue(proxy.key, proxy.value)}), but LangBot venv python was not found; after creating the venv, verify it can import socksio.`; + } + + const result = spawnSync(python, ["-c", "import socksio"], { + encoding: "utf8", + timeout: 5000, + }); + if (result.status === 0) return null; + + return `SOCKS proxy ${proxy.key} is configured (${redactEnvValue(proxy.key, proxy.value)}), but ${python} cannot import socksio; run \`${python} -m pip install socksio\` or start LangBot without SOCKS proxy env.`; +} + +export async function commandEnvDoctor(ctx: CommandContext): Promise { + const env = loadEnv(ctx.root); + const failures: string[] = []; + const warnings: string[] = []; + + for (const key of requiredEnvKeys) { + if (!env[key]) failures.push(`missing ${key}`); + } + + for (const [label, path] of [ + ["LANGBOT_REPO", env.LANGBOT_REPO], + ["LANGBOT_WEB_REPO", env.LANGBOT_WEB_REPO], + ["LANGBOT_CHROMIUM_EXECUTABLE", env.LANGBOT_CHROMIUM_EXECUTABLE], + ]) { + if (!path || !existsSync(path)) failures.push(`${label}: path does not exist (${path || "missing"})`); + } + + if (env.LANGBOT_BROWSER_PROFILE && !existsSync(env.LANGBOT_BROWSER_PROFILE)) { + warnings.push(`LANGBOT_BROWSER_PROFILE: path does not exist yet (${env.LANGBOT_BROWSER_PROFILE})`); + } + + for (const mismatch of [ + compareProxyPair(env, "HTTP_PROXY", "http_proxy"), + compareProxyPair(env, "HTTPS_PROXY", "https_proxy"), + compareProxyPair(env, "ALL_PROXY", "all_proxy"), + compareProxyPair(env, "NO_PROXY", "no_proxy"), + ]) { + if (mismatch) failures.push(mismatch); + } + const socksioFailure = checkSocksio(env); + if (socksioFailure) failures.push(socksioFailure); + + for (const [label, result] of await Promise.all([ + checkUrl("LANGBOT_BACKEND_URL", env.LANGBOT_BACKEND_URL).then((result) => ["LANGBOT_BACKEND_URL", result] as const), + checkUrl("LANGBOT_FRONTEND_URL", env.LANGBOT_FRONTEND_URL).then((result) => ["LANGBOT_FRONTEND_URL", result] as const), + ])) { + if (result.ok) console.log(`OK: ${result.message}`); + else { + failures.push(result.message); + const tcp = await checkTcpListener(env[label]); + if (tcp && !tcp.ok) failures.push(`${label}: no HTTP service reachable because ${tcp.message}`); + const hint = startupHint(label, env); + if (hint) warnings.push(`${label}: ${hint}`); + } + } + + for (const warning of warnings) console.log(`WARN: ${warning}`); + for (const failure of failures) console.log(`FAIL: ${failure}`); + if (failures.length > 0) return 1; + console.log("OK: environment looks usable"); + return 0; +} diff --git a/skills/src/commands/fixture.ts b/skills/src/commands/fixture.ts new file mode 100644 index 000000000..6c5bf72aa --- /dev/null +++ b/skills/src/commands/fixture.ts @@ -0,0 +1,132 @@ +import type { CommandContext } from "../types.ts"; +import { parseOptions } from "../cli.ts"; +import { loadFixtureItems } from "../fixtures.ts"; +import { dirname, join } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; + +function fixtureRows(root: string, skill: string | undefined): ReturnType { + return loadFixtureItems(root, skill); +} + +function qaAgentRunnerSourceFindings(item: ReturnType["items"][number]) { + if (!item.checks.includes("qa_agent_runner_source") || !item.exists) return []; + const root = dirname(item.absolute_path); + const required = [ + "main.py", + "components/agent_runner/default.yaml", + "components/agent_runner/default.py", + "assets/icon.svg", + ]; + const missing = required + .filter((path) => !existsSync(join(root, path))) + .map((path) => ({ + severity: "fail", + kind: "fixture_check_missing_file", + id: item.id, + path: `${item.path.replace(/\/[^/]+$/, "")}/${path}`, + })); + if (missing.length > 0) return missing; + + const manifest = readFileSync(item.absolute_path, "utf8"); + const runnerYaml = readFileSync(join(root, "components/agent_runner/default.yaml"), "utf8"); + const runnerPy = readFileSync(join(root, "components/agent_runner/default.py"), "utf8"); + const requiredText = [ + [manifest, "AgentRunner", "manifest.yaml"], + [manifest, "QAAgentRunnerPlugin", "manifest.yaml"], + [runnerYaml, "kind: AgentRunner", "components/agent_runner/default.yaml"], + [runnerYaml, "DefaultAgentRunner", "components/agent_runner/default.yaml"], + [runnerPy, "QA_AGENT_RUNNER_OK", "components/agent_runner/default.py"], + [runnerPy, "QA_AGENT_RUNNER_CONTROLLED_FAILURE", "components/agent_runner/default.py"], + ]; + return requiredText + .filter(([text, needle]) => !text.includes(needle)) + .map(([, needle, relativePath]) => ({ + severity: "fail", + kind: "fixture_check_missing_text", + id: item.id, + path: `${item.path.replace(/\/[^/]+$/, "")}/${relativePath}`, + detail: `missing ${needle}`, + })); +} + +function zipPackageFindings(item: ReturnType["items"][number]) { + if (!item.checks.includes("zip_package") || !item.exists) return []; + const header = readFileSync(item.absolute_path).subarray(0, 4).toString("binary"); + if (header === "PK\u0003\u0004" || header === "PK\u0005\u0006") return []; + return [{ + severity: "fail", + kind: "fixture_check_invalid_zip", + id: item.id, + path: item.path, + }]; +} + +export function commandFixtureList(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(2)); + const skill = positional[0]; + const result = fixtureRows(ctx.root, skill); + + if (options.json === true) { + console.log(JSON.stringify(result.items, null, 2)); + return result.errors.length > 0 ? 1 : 0; + } + + for (const item of result.items) { + console.log([ + item.skill, + item.id, + item.kind, + item.exists ? "present" : "missing", + item.path, + item.title, + ].join("\t")); + } + for (const error of result.errors) console.error(`ERROR: ${error}`); + return result.errors.length > 0 ? 1 : 0; +} + +export function commandFixtureCheck(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(2)); + const skill = positional[0]; + const result = fixtureRows(ctx.root, skill); + const findings = [ + ...result.errors.map((error) => ({ severity: "fail", kind: "invalid_manifest", detail: error })), + ...result.items + .filter((item) => !item.exists) + .map((item) => ({ + severity: "fail", + kind: "missing_fixture", + id: item.id, + path: item.path, + absolute_path: item.absolute_path, + })), + ...result.items.flatMap(qaAgentRunnerSourceFindings), + ...result.items.flatMap(zipPackageFindings), + ]; + const report = { + status: findings.some((finding) => finding.severity === "fail") ? "fail" : "pass", + fixture_count: result.items.length, + findings, + fixtures: result.items, + }; + + if (options.json === true) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(`# Fixture Check`); + console.log(""); + console.log(`status: ${report.status}`); + console.log(`fixture_count: ${report.fixture_count}`); + console.log(""); + console.log("## Fixtures"); + for (const item of result.items) { + console.log(`- ${item.id}: ${item.exists ? "present" : "missing"} (${item.path})`); + } + console.log(""); + console.log("## Findings"); + if (findings.length === 0) console.log("- None."); + else for (const finding of findings) console.log(`- [${finding.severity}] ${finding.kind}: ${"detail" in finding ? finding.detail : finding.id}`); + } + + return report.status === "pass" ? 0 : 1; +} diff --git a/skills/src/commands/log.ts b/skills/src/commands/log.ts new file mode 100644 index 000000000..aad9eaa66 --- /dev/null +++ b/skills/src/commands/log.ts @@ -0,0 +1,427 @@ +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { setTimeout as delay } from "node:timers/promises"; +import { dirname, join, resolve } from "node:path"; +import type { CommandContext } from "../types.ts"; +import { optionString, parseOptions, usage } from "../cli.ts"; +import { findStructuredItem, loadEnv } from "../fs.ts"; +import { + latestLangBotLogPath, + logPatternContextFromStructuredItem, + renderLogFinding, + renderLogSuccessSignal, + scanLogSources, + scanLogText, + strictLogGuardExitCode, + type LogFinding, + type LogGuardPatternContext, + type LogGuardResult, + type LogSuccessSignal, +} from "../log-guard.ts"; + +type LogGuardSession = { + source: "log-guard-session"; + run_id: string; + started_at: string; + started_at_local: string; + backend_log: string; + case_id: string; + case_skill: string; +}; + +type WatchSummary = { + mode: "watch"; + status: string; + path: string; + started_at_local: string; + finished_at_local: string; + bytes_read: number; + findings: LogFinding[]; + success_signals: LogSuccessSignal[]; +}; + +function pad2(value: number): string { + return String(value).padStart(2, "0"); +} + +function pad3(value: number): string { + return String(value).padStart(3, "0"); +} + +function localIsoWithOffset(date: Date): string { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absoluteOffset = Math.abs(offsetMinutes); + return [ + `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`, + `T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${pad3(date.getMilliseconds())}`, + `${sign}${pad2(Math.floor(absoluteOffset / 60))}:${pad2(absoluteOffset % 60)}`, + ].join(""); +} + +function timestampSlug(localIso: string): string { + return localIso + .replace(/T/, "-") + .replace(/[.:+]/g, "-") + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function writeOrPrint(content: string, output: string | undefined): void { + if (!output) { + console.log(content.trimEnd()); + return; + } + const path = resolve(output); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, "utf8"); + console.log(path); +} + +function positiveIntegerOption(options: Record, key: string, fallback: number): number { + const raw = optionString(options, key); + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + if (!/^\d+$/.test(raw) || parsed <= 0) return fallback; + return parsed; +} + +function splitPatternList(value: string | undefined): string[] { + if (!value) return []; + return value + .split(/\s*\|\s*|\s*,\s*/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function patternContextFromOptions(root: string, options: Record): LogGuardPatternContext { + const caseId = optionString(options, "case"); + const base = caseId ? logPatternContextFromStructuredItem(findStructuredItem(root, "cases", caseId)) : {}; + return { + successPatterns: [ + ...(base.successPatterns ?? []), + ...splitPatternList(optionString(options, "success-pattern")), + ], + failurePatterns: [ + ...(base.failurePatterns ?? []), + ...splitPatternList(optionString(options, "failure-pattern")), + ], + expectedFailures: [ + ...(base.expectedFailures ?? []), + ...splitPatternList(optionString(options, "expected-failure")), + ], + relatedTroubleshootingIds: base.relatedTroubleshootingIds ?? [], + }; +} + +function latestOrExplicitBackendLog(root: string, options: Record): string { + const explicit = optionString(options, "backend-log"); + if (explicit) return resolve(explicit); + const auto = latestLangBotLogPath(loadEnv(root)); + return auto ? resolve(auto) : ""; +} + +function renderSources(result: LogGuardResult): string[] { + const lines: string[] = []; + if (result.sources.length === 0) { + lines.push("- sources: no log files provided; use --backend-log or configure LANGBOT_REPO."); + return lines; + } + lines.push("- sources:"); + for (const source of result.sources) { + const origin = source.auto_detected ? ", auto" : ""; + const total = source.total_line_count === undefined ? "" : `/${source.total_line_count}`; + const range = source.start_line === undefined || source.end_line === undefined + ? "" + : `, lines ${source.start_line}-${source.end_line}`; + const timestamped = source.timestamped_line_count === undefined ? "" : `, ${source.timestamped_line_count} timestamped`; + lines.push(` - ${source.source}: ${source.path} (${source.status}${origin}, ${source.line_count}${total} lines${range}${timestamped})`); + } + return lines; +} + +function renderLogGuardMarkdown(title: string, result: LogGuardResult, extra: string[] = []): string { + const lines: string[] = []; + lines.push(`# ${title}`); + lines.push(""); + lines.push(`Generated: ${new Date().toISOString()}`); + lines.push(`Status: ${result.status}`); + lines.push(`Scan mode: ${result.scan.mode}`); + if (result.scan.since) lines.push(`Since: ${result.scan.since}`); + if (result.scan.until) lines.push(`Until: ${result.scan.until}`); + if (result.scan.tail_lines !== undefined) lines.push(`Tail lines: ${result.scan.tail_lines}`); + if (extra.length > 0) { + lines.push(""); + lines.push("## Context"); + for (const item of extra) lines.push(`- ${item}`); + } + lines.push(""); + lines.push("## Sources"); + lines.push(...renderSources(result)); + if (result.scan.warnings.length > 0) { + lines.push(""); + lines.push("## Scan Warnings"); + for (const warning of result.scan.warnings) lines.push(`- ${warning}`); + } + lines.push(""); + lines.push("## Findings"); + if (result.findings.length === 0) lines.push("- None."); + else for (const finding of result.findings) lines.push(renderLogFinding(finding)); + lines.push(""); + lines.push("## Success Signals"); + if (result.success_signals.length === 0) lines.push("- None."); + else for (const signal of result.success_signals) lines.push(renderLogSuccessSignal(signal)); + lines.push(""); + return `${lines.join("\n").trimEnd()}\n`; +} + +function statusFromEvents(findings: LogFinding[], successSignals: LogSuccessSignal[]): string { + if (findings.some((finding) => finding.severity === "fail" || finding.severity === "missing_input")) return "fail"; + if (findings.some((finding) => finding.severity === "matched_troubleshooting" && finding.related_to_case !== false)) return "fail"; + if (findings.some((finding) => finding.severity === "env_issue")) return "env_issue"; + if (findings.some((finding) => finding.severity === "warning")) return "warning"; + if (successSignals.length > 0) return "pass"; + return "no_activity"; +} + +function strictSummaryExitCode(status: string): number { + return status === "fail" || status === "env_issue" ? 1 : 0; +} + +function sessionDir(options: Record): string { + return optionString(options, "output-dir") ?? join("reports", "log-guards"); +} + +function sessionPath(options: Record, runId: string): string { + return join(sessionDir(options), `${runId}.json`); +} + +function readSession(options: Record): LogGuardSession | undefined { + const runId = optionString(options, "run-id"); + const explicitSession = optionString(options, "session"); + const path = explicitSession ? resolve(explicitSession) : runId ? resolve(sessionPath(options, runId)) : ""; + if (!path || !existsSync(path)) return undefined; + return JSON.parse(readFileSync(path, "utf8")) as LogGuardSession; +} + +export function commandLogScan(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(2)); + if (positional.length > 0) usage(); + + const result = scanLogSources(ctx.root, options, patternContextFromOptions(ctx.root, options)); + const output = optionString(options, "output"); + const content = options.json === true + ? `${JSON.stringify(result, null, 2)}\n` + : renderLogGuardMarkdown("Log Guard Scan", result, [ + optionString(options, "case") ? `case: ${optionString(options, "case")}` : "case: none", + options.strict === true ? "strict: yes" : "strict: no", + ]); + writeOrPrint(content, output); + return options.strict === true ? strictLogGuardExitCode(result) : 0; +} + +export async function commandLogWatch(ctx: CommandContext): Promise { + const { positional, options } = parseOptions(ctx.args.slice(2)); + if (positional.length > 0) usage(); + + const path = latestOrExplicitBackendLog(ctx.root, options); + if (!path) { + console.error("ERROR: no backend log found; pass --backend-log or configure LANGBOT_REPO."); + return 1; + } + if (!existsSync(path)) { + console.error(`ERROR: backend log does not exist: ${path}`); + return 1; + } + + const context = patternContextFromOptions(ctx.root, options); + const intervalMs = positiveIntegerOption(options, "interval-ms", 1000); + const durationMs = optionString(options, "duration-ms") + ? positiveIntegerOption(options, "duration-ms", 0) + : 0; + const startedAtLocal = localIsoWithOffset(new Date()); + const findings: LogFinding[] = []; + const successSignals: LogSuccessSignal[] = []; + let bytesRead = 0; + let offset = options["from-start"] === true ? 0 : statSync(path).size; + let baseLineNumber = options["from-start"] === true + ? 0 + : readFileSync(path).subarray(0, offset).toString("utf8").split(/\r?\n/).length - 1; + let carry = ""; + + if (options.json !== true) { + console.log(`# Log Guard Watch`); + console.log(`Path: ${path}`); + console.log(`Started: ${startedAtLocal}`); + console.log(`Mode: ${options["from-start"] === true ? "from-start" : "new-lines"}`); + } + + const startedMs = Date.now(); + let stopRequested = false; + const stop = (): void => { + stopRequested = true; + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + + const poll = (): void => { + const buffer = readFileSync(path); + if (buffer.length < offset) { + offset = 0; + baseLineNumber = 0; + carry = ""; + } + if (buffer.length === offset) return; + + const chunk = buffer.subarray(offset).toString("utf8"); + offset = buffer.length; + bytesRead += Buffer.byteLength(chunk); + const text = `${carry}${chunk}`; + const hasCompleteLine = /\r?\n$/.test(text); + const lastNewline = Math.max(text.lastIndexOf("\n"), text.lastIndexOf("\r")); + if (!hasCompleteLine && lastNewline === -1) { + carry = text; + return; + } + + const complete = hasCompleteLine ? text : text.slice(0, lastNewline + 1); + carry = hasCompleteLine ? "" : text.slice(lastNewline + 1); + if (!complete) return; + + const result = scanLogText(ctx.root, "backend", path, complete, {}, context, baseLineNumber, false); + baseLineNumber += complete.split(/\r?\n/).length - 1; + findings.push(...result.findings); + successSignals.push(...result.success_signals); + + if (options.json !== true) { + for (const finding of result.findings) console.log(renderLogFinding(finding)); + for (const signal of result.success_signals) console.log(renderLogSuccessSignal(signal)); + } + }; + + try { + do { + poll(); + if (stopRequested) break; + if (durationMs > 0 && Date.now() - startedMs >= durationMs) break; + await delay(Math.min(intervalMs, durationMs > 0 ? Math.max(1, durationMs - (Date.now() - startedMs)) : intervalMs)); + } while (!stopRequested); + + if (carry) { + const result = scanLogText(ctx.root, "backend", path, carry, {}, context, baseLineNumber, false); + findings.push(...result.findings); + successSignals.push(...result.success_signals); + if (options.json !== true) { + for (const finding of result.findings) console.log(renderLogFinding(finding)); + for (const signal of result.success_signals) console.log(renderLogSuccessSignal(signal)); + } + } + } finally { + process.off("SIGINT", stop); + process.off("SIGTERM", stop); + } + + const summary: WatchSummary = { + mode: "watch", + status: statusFromEvents(findings, successSignals), + path, + started_at_local: startedAtLocal, + finished_at_local: localIsoWithOffset(new Date()), + bytes_read: bytesRead, + findings, + success_signals: successSignals, + }; + + if (options.json === true) { + console.log(JSON.stringify(summary, null, 2)); + } else { + console.log(`Status: ${summary.status}`); + console.log(`Bytes read: ${summary.bytes_read}`); + } + return options.strict === true ? strictSummaryExitCode(summary.status) : 0; +} + +export function commandLogGuard(ctx: CommandContext): number { + const sub = ctx.args[2]; + if (sub === "start") return commandLogGuardStart(ctx); + if (sub === "stop") return commandLogGuardStop(ctx); + usage(); +} + +function commandLogGuardStart(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(3)); + if (positional.length > 0) usage(); + + const now = new Date(); + const startedAtLocal = localIsoWithOffset(now); + const runId = optionString(options, "run-id") ?? `log-guard-${timestampSlug(startedAtLocal)}`; + const caseId = optionString(options, "case") ?? ""; + const caseItem = caseId ? findStructuredItem(ctx.root, "cases", caseId) : undefined; + const session: LogGuardSession = { + source: "log-guard-session", + run_id: runId, + started_at: now.toISOString(), + started_at_local: startedAtLocal, + backend_log: latestOrExplicitBackendLog(ctx.root, options), + case_id: caseId, + case_skill: caseItem?.skill ?? "", + }; + const path = resolve(sessionPath(options, runId)); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(session, null, 2)}\n`, "utf8"); + + const result = { + ...session, + path, + stop_command: `bin/lbs log guard stop --run-id ${runId} --output-dir ${sessionDir(options)}`, + }; + if (options.json === true) console.log(JSON.stringify(result, null, 2)); + else { + console.log(`# Log Guard Session`); + console.log(`Run: ${runId}`); + console.log(`Started: ${startedAtLocal}`); + console.log(`Session: ${path}`); + if (session.backend_log) console.log(`Backend log: ${session.backend_log}`); + if (session.case_id) console.log(`Case: ${session.case_id}`); + console.log(`Stop: ${result.stop_command}`); + } + return 0; +} + +function commandLogGuardStop(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(3)); + if (positional.length > 0) usage(); + + const session = readSession(options); + if (!session) { + console.error("ERROR: log guard session not found; pass --run-id with --output-dir or --session."); + return 1; + } + + const now = new Date(); + const scanOptions: Record = { + ...options, + since: optionString(options, "since") ?? session.started_at_local, + until: optionString(options, "until") ?? localIsoWithOffset(now), + }; + if (session.backend_log && typeof scanOptions["backend-log"] !== "string") { + scanOptions["backend-log"] = session.backend_log; + } + + const caseId = optionString(options, "case") ?? session.case_id; + const context = caseId + ? logPatternContextFromStructuredItem(findStructuredItem(ctx.root, "cases", caseId)) + : patternContextFromOptions(ctx.root, options); + const result = scanLogSources(ctx.root, scanOptions, context); + const output = optionString(options, "output") ?? join(sessionDir(options), `${session.run_id}.md`); + const content = options.json === true + ? `${JSON.stringify({ session, result }, null, 2)}\n` + : renderLogGuardMarkdown("Log Guard Report", result, [ + `run_id: ${session.run_id}`, + `started: ${session.started_at_local}`, + `finished: ${scanOptions.until}`, + caseId ? `case: ${caseId}` : "case: none", + ]); + writeOrPrint(content, options.json === true ? optionString(options, "output") : output); + return options["no-strict"] === true ? 0 : strictLogGuardExitCode(result); +} diff --git a/skills/src/commands/skill.ts b/skills/src/commands/skill.ts new file mode 100644 index 000000000..7efbc0d79 --- /dev/null +++ b/skills/src/commands/skill.ts @@ -0,0 +1,128 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { CommandContext } from "../types.ts"; +import { fail, optionString, parseOptions, usage } from "../cli.ts"; +import { loadFixtureItems } from "../fixtures.ts"; +import { boolValue, getSkill, globMarkdownRefs, listValue, loadSkills, loadStructuredItems, scalar, skillsRoot } from "../fs.ts"; + +export function commandList(ctx: CommandContext): number { + for (const skill of loadSkills(ctx.root)) { + console.log(`${skill.directory}\t${skill.name}\t${skill.description}`); + } + return 0; +} + +function buildIndexData(root: string): Record { + const caseSummary = (item: ReturnType[number]) => ({ + id: scalar(item.fields, "id"), + title: scalar(item.fields, "title"), + mode: scalar(item.fields, "mode"), + area: scalar(item.fields, "area"), + type: scalar(item.fields, "type"), + priority: scalar(item.fields, "priority"), + risk: scalar(item.fields, "risk"), + ci_eligible: boolValue(item.fields, "ci_eligible") ?? false, + tags: listValue(item.fields, "tags"), + automation: scalar(item.fields, "automation"), + setup_automation: listValue(item.fields, "setup_automation"), + setup_provides_env: listValue(item.fields, "setup_provides_env"), + evidence_required: listValue(item.fields, "evidence_required"), + }); + const troubleshootingSummary = (item: ReturnType[number]) => ({ + id: scalar(item.fields, "id"), + title: scalar(item.fields, "title"), + category: scalar(item.fields, "category") || "product", + related_cases: listValue(item.fields, "related_cases"), + }); + const suiteSummary = (item: ReturnType[number]) => ({ + id: scalar(item.fields, "id"), + title: scalar(item.fields, "title"), + description: scalar(item.fields, "description"), + type: scalar(item.fields, "type"), + priority: scalar(item.fields, "priority"), + tags: listValue(item.fields, "tags"), + cases: listValue(item.fields, "cases"), + }); + return { + generated_by: "lbs", + skills: loadSkills(root).map((skill) => ({ + directory: skill.directory, + name: skill.name, + description: skill.description, + references: globMarkdownRefs(skill.path), + cases: loadStructuredItems(root, "cases", skill.directory).map((item) => scalar(item.fields, "id")), + case_summaries: loadStructuredItems(root, "cases", skill.directory).map(caseSummary), + suites: loadStructuredItems(root, "suites", skill.directory).map((item) => scalar(item.fields, "id")), + suite_summaries: loadStructuredItems(root, "suites", skill.directory).map(suiteSummary), + fixtures: loadFixtureItems(root, skill.directory).items.map((item) => ({ + id: item.id, + title: item.title, + kind: item.kind, + path: item.path, + related_cases: item.related_cases, + })), + troubleshooting: loadStructuredItems(root, "troubleshooting", skill.directory).map((item) => scalar(item.fields, "id")), + troubleshooting_summaries: loadStructuredItems(root, "troubleshooting", skill.directory).map(troubleshootingSummary), + })), + }; +} + +export function commandIndex(ctx: CommandContext): number { + const { options } = parseOptions(ctx.args.slice(1)); + const data = buildIndexData(ctx.root); + const out = join(ctx.root, "skills.index.json"); + const content = `${JSON.stringify(data, null, 2)}\n`; + if (options.check === true) { + if (!existsSync(out)) { + console.error(`ERROR: missing index: ${out}`); + return 1; + } + if (readFileSync(out, "utf8") !== content) { + console.error(`ERROR: index is stale: ${out}`); + return 1; + } + console.log(`OK ${out}`); + return 0; + } + writeFileSync(out, content, "utf8"); + console.log(out); + return 0; +} + +export function commandNewSkill(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(1)); + const name = positional[0]; + if (!name) usage(); + + const skillDir = join(skillsRoot(ctx.root), name); + const skillMd = join(skillDir, "SKILL.md"); + if (existsSync(skillMd)) fail(`skill already exists: ${skillDir}`); + + mkdirSync(skillDir, { recursive: true }); + const description = optionString(options, "description") ?? `Use when working with ${name}.`; + const text = + `---\nname: ${name}\ndescription: ${description}\n---\n\n` + + `# ${name}\n\n` + + "Add concise routing and workflow instructions here.\n"; + writeFileSync(skillMd, text, "utf8"); + console.log(skillMd); + return 0; +} + +export function commandNewRef(ctx: CommandContext): number { + const skill = ctx.args[1]; + const rawName = ctx.args[2]; + if (!skill || !rawName) usage(); + + const skillDir = getSkill(ctx.root, skill).path; + const refsDir = join(skillDir, "references"); + mkdirSync(refsDir, { recursive: true }); + const name = rawName.endsWith(".md") ? rawName : `${rawName}.md`; + const refPath = join(refsDir, name); + if (existsSync(refPath)) fail(`reference already exists: ${refPath}`); + + const title = name.replace(/\.md$/, "").replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); + writeFileSync(refPath, `# ${title}\n\nAdd concise reusable instructions here.\n`, "utf8"); + console.log(refPath); + return 0; +} diff --git a/skills/src/commands/suite.ts b/skills/src/commands/suite.ts new file mode 100644 index 000000000..7ab556c5b --- /dev/null +++ b/skills/src/commands/suite.ts @@ -0,0 +1,745 @@ +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { execPath } from "node:process"; +import type { CommandContext, StructuredItem } from "../types.ts"; +import { fail, optionString, parseOptions, usage } from "../cli.ts"; +import { findStructuredItem, getSkill, listValue, loadStructuredItems, scalar, yamlList, yamlQuote } from "../fs.ts"; +import { caseAutomationReadiness, caseEnvReadiness, caseFixtureReadiness, caseManualReadiness, runtimeEnv } from "../readiness.ts"; +import { lbsScriptPath, setupAutomationEntries } from "../setup-automation.ts"; + +function suitePath(root: string, skillName: string, id: string): string { + const skill = getSkill(root, skillName); + const dir = join(skill.path, "suites"); + mkdirSync(dir, { recursive: true }); + return join(dir, `${id}.yaml`); +} + +function caseItemById(root: string, id: string): StructuredItem { + return findStructuredItem(root, "cases", id); +} + +function suiteCaseSummary(root: string, id: string): Record { + const item = caseItemById(root, id); + const env = runtimeEnv(root); + const caseId = scalar(item.fields, "id"); + return { + skill: item.skill, + id: caseId, + title: scalar(item.fields, "title"), + mode: scalar(item.fields, "mode"), + area: scalar(item.fields, "area"), + type: scalar(item.fields, "type"), + priority: scalar(item.fields, "priority"), + risk: scalar(item.fields, "risk"), + tags: listValue(item.fields, "tags"), + preconditions: listValue(item.fields, "preconditions"), + setup: listValue(item.fields, "setup"), + setup_automation: setupAutomationEntries(item), + setup_provides_env: listValue(item.fields, "setup_provides_env"), + automation: scalar(item.fields, "automation"), + evidence_required: listValue(item.fields, "evidence_required"), + env_readiness: caseEnvReadiness(item, env), + automation_readiness: caseAutomationReadiness(item, env), + fixture_readiness: caseFixtureReadiness(root, caseId), + manual_readiness: caseManualReadiness(item), + }; +} + +function suiteSummary(item: StructuredItem): Record { + return { + skill: item.skill, + id: scalar(item.fields, "id"), + title: scalar(item.fields, "title"), + description: scalar(item.fields, "description"), + type: scalar(item.fields, "type"), + priority: scalar(item.fields, "priority"), + tags: listValue(item.fields, "tags"), + cases: listValue(item.fields, "cases"), + }; +} + +function findSuite(root: string, args: string[]): StructuredItem { + if (args.length < 1 || args.length > 2) usage(); + return args.length === 1 + ? findStructuredItem(root, "suites", args[0]) + : findStructuredItem(root, "suites", args[0], args[1]); +} + +function pad2(value: number): string { + return String(value).padStart(2, "0"); +} + +function pad3(value: number): string { + return String(value).padStart(3, "0"); +} + +function localIsoWithOffset(date: Date): string { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absoluteOffset = Math.abs(offsetMinutes); + return [ + `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`, + `T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${pad3(date.getMilliseconds())}`, + `${sign}${pad2(Math.floor(absoluteOffset / 60))}:${pad2(absoluteOffset % 60)}`, + ].join(""); +} + +function timestampSlug(localIso: string): string { + return localIso + .replace(/T/, "-") + .replace(/[.:+]/g, "-") + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function writeOrPrint(content: string, output: string | undefined): void { + if (!output) { + console.log(content.trimEnd()); + return; + } + const path = resolve(output); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, "utf8"); + console.log(path); +} + +function suiteCases(root: string, item: StructuredItem): Record[] { + return listValue(item.fields, "cases").map((id) => suiteCaseSummary(root, id)); +} + +function statusOf(caseItem: Record, key: string): string { + const value = caseItem[key] as Record | undefined; + return typeof value?.status === "string" ? value.status : "not_required"; +} + +function readinessSummary(cases: Array>): Record { + const missingEnv = cases.filter((item) => statusOf(item, "env_readiness") === "missing").map((item) => item.id); + const missingAutomation = cases.filter((item) => statusOf(item, "automation_readiness") === "missing").map((item) => item.id); + const missingFixture = cases.filter((item) => statusOf(item, "fixture_readiness") === "missing").map((item) => item.id); + const manualCheck = cases.filter((item) => statusOf(item, "manual_readiness") === "manual_check").map((item) => item.id); + const missingCount = missingEnv.length + missingAutomation.length + missingFixture.length; + return { + status: missingCount > 0 ? "missing" : manualCheck.length > 0 ? "manual_check" : "ready", + missing_env_cases: missingEnv, + missing_automation_env_cases: missingAutomation, + missing_fixture_cases: missingFixture, + manual_check_cases: manualCheck, + }; +} + +function hasProbeCases(cases: Array>): boolean { + return cases.some((caseItem) => caseItem.mode === "probe"); +} + +function suiteReportGuidance(cases: Array>): string { + return hasProbeCases(cases) + ? "Run each case according to its mode; probe cases may collect non-UI evidence, while agent-browser cases still require browser/UI execution." + : "Run each case through browser/UI first; use test report with the evidence directory and backend log window after execution."; +} + +function suiteResultPolicy(cases: Array>): string[] { + if (hasProbeCases(cases)) { + return [ + "A suite is not pass unless every case has a result and required evidence for the same run window.", + "agent-browser cases require UI/browser results; probe cases are judged by their declared checks and required evidence.", + "blocked and env_issue are not product pass; report them separately.", + ]; + } + + return [ + "A suite is not pass unless every case has a UI/browser result and required evidence for the same run window.", + "blocked and env_issue are not product pass; report them separately.", + ]; +} + +function suiteEvidencePolicy(cases: Array>): string[] { + if (hasProbeCases(cases)) { + return [ + "Run each case according to its mode. Agent-browser cases use browser/UI; probe cases use their declared probe steps or automation.", + "Use each case evidence_dir for screenshots, console.log, network.log, automation-result.json, result.json, and any probe artifacts.", + "After case execution and report review, run each result_command_template with the final status and collected evidence.", + "After per-case result.json files exist, run the suite report command to aggregate them.", + "blocked and env_issue are not product pass; they must be reported separately from pass.", + ]; + } + + return [ + "Run each case through browser/UI. API/curl/log diagnostics cannot make a UI case pass by themselves.", + "Use each case evidence_dir for screenshots, console.log, network.log, automation-result.json, and final result.json.", + "After case execution and report review, run each result_command_template with the final status and collected evidence.", + "After per-case result.json files exist, run the suite report command to aggregate them.", + "blocked and env_issue are not product pass; they must be reported separately from pass.", + ]; +} + +function buildSuitePlan(root: string, item: StructuredItem): Record { + const suite = suiteSummary(item); + const cases = suiteCases(root, item); + return { + ...suite, + cases, + readiness: readinessSummary(cases), + commands: cases.map((caseItem) => ({ + id: caseItem.id, + plan: `bin/lbs test plan ${caseItem.id}`, + start: `bin/lbs test start ${caseItem.id}`, + automation: caseItem.automation ? `bin/lbs test run ${caseItem.id} --dry-run` : "", + })), + report_guidance: suiteReportGuidance(cases), + }; +} + +export function commandSuiteNew(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(2)); + const id = positional[0]; + const title = optionString(options, "title"); + if (!id || !title) usage(); + + const skill = optionString(options, "skill") ?? "langbot-testing"; + const path = suitePath(ctx.root, skill, id); + if (existsSync(path)) fail(`suite already exists: ${path}`); + + const text = + `id: ${id}\n` + + `title: ${yamlQuote(title)}\n` + + `description: ${yamlQuote(optionString(options, "description") ?? "Describe when to run this suite.")}\n` + + `type: ${optionString(options, "type") ?? "smoke"}\n` + + `priority: ${optionString(options, "priority") ?? "p2"}\n` + + "tags:\n" + + yamlList([optionString(options, "type") ?? "smoke"]) + + "\ncases:\n" + + yamlList(["webui-login-state"]) + + "\n"; + + writeFileSync(path, text, "utf8"); + console.log(path); + return 0; +} + +export function commandSuiteList(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(2)); + const skill = positional[0]; + const rows = loadStructuredItems(ctx.root, "suites", skill) + .map(suiteSummary) + .filter((row) => !optionString(options, "type") || row.type === optionString(options, "type")) + .filter((row) => !optionString(options, "priority") || row.priority === optionString(options, "priority")); + + if (options.json === true) { + console.log(JSON.stringify(rows, null, 2)); + return 0; + } + + for (const row of rows) { + console.log([ + row.skill, + row.id, + row.type, + row.priority, + Array.isArray(row.cases) ? row.cases.length : 0, + row.title, + ].join("\t")); + } + return 0; +} + +export function commandSuiteShow(ctx: CommandContext): number { + const item = findSuite(ctx.root, ctx.args.slice(2)); + console.log(item.raw.trimEnd()); + return 0; +} + +export function commandSuitePlan(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findSuite(ctx.root, args); + const plan = buildSuitePlan(ctx.root, item); + const suite = suiteSummary(item); + const cases = suiteCases(ctx.root, item); + + if (options.json === true) { + console.log(JSON.stringify(plan, null, 2)); + return 0; + } + + console.log(`# Suite Plan: ${suite.id}`); + console.log(""); + console.log(`Title: ${suite.title}`); + console.log(`Type: ${suite.type}`); + console.log(`Priority: ${suite.priority}`); + console.log(`Description: ${suite.description}`); + console.log(""); + const readiness = readinessSummary(cases); + console.log("## Readiness"); + console.log(`Status: ${readiness.status}`); + for (const [key, value] of Object.entries(readiness)) { + if (key === "status" || !Array.isArray(value) || value.length === 0) continue; + console.log(`- ${key}: ${value.join(", ")}`); + } + console.log(""); + console.log("## Cases"); + for (const [index, caseItem] of cases.entries()) { + console.log(`${index + 1}. ${caseItem.id} [${caseItem.priority}/${caseItem.risk}] ${caseItem.title}`); + console.log(` - plan: bin/lbs test plan ${caseItem.id}`); + console.log(` - start: bin/lbs test start ${caseItem.id}`); + if (caseItem.automation) console.log(` - automation dry-run: bin/lbs test run ${caseItem.id} --dry-run`); + console.log(` - evidence: ${Array.isArray(caseItem.evidence_required) ? caseItem.evidence_required.join(", ") : ""}`); + const envReadiness = caseItem.env_readiness as Record; + const automationReadiness = caseItem.automation_readiness as Record; + const fixtureReadiness = caseItem.fixture_readiness as Record; + const manualReadiness = caseItem.manual_readiness as Record; + const missing: string[] = []; + if (Array.isArray(envReadiness.missing) && envReadiness.missing.length > 0) missing.push(`env=${envReadiness.missing.join(",")}`); + if (Array.isArray(automationReadiness.missing) && automationReadiness.missing.length > 0) missing.push(`automation_env=${automationReadiness.missing.join(",")}`); + if (Array.isArray(fixtureReadiness.missing) && fixtureReadiness.missing.length > 0) missing.push(`fixture=${fixtureReadiness.missing.join(",")}`); + const manualLabel = manualReadiness.status === "manual_check" ? " manual_check" : ""; + console.log(` - readiness: ${missing.length === 0 ? `ready${manualLabel}` : `missing ${missing.join(" ")}`}`); + const preconditions = caseItem.preconditions; + if (Array.isArray(preconditions) && preconditions.length > 0) console.log(` - preconditions: ${preconditions.length}`); + const setupAutomation = caseItem.setup_automation; + if (Array.isArray(setupAutomation) && setupAutomation.length > 0) console.log(` - setup automation: ${setupAutomation.length}`); + } + console.log(""); + console.log("## Result Policy"); + for (const policy of suiteResultPolicy(cases)) console.log(`- ${policy}`); + return 0; +} + +function suiteStartPath(root: string, path: string): string { + return resolve(root, path); +} + +function ensureDirectory(root: string, path: string, label: string): void { + const resolvedPath = suiteStartPath(root, path); + if (existsSync(resolvedPath) && !statSync(resolvedPath).isDirectory()) { + fail(`${label} exists and is not a directory: ${resolvedPath}`); + } + mkdirSync(resolvedPath, { recursive: true }); +} + +function buildSuiteStart( + root: string, + item: StructuredItem, + args: string[], + options: Record, +): Record { + const now = new Date(); + const startedAtLocal = localIsoWithOffset(now); + const suite = suiteSummary(item); + const suiteId = String(suite.id); + const runId = optionString(options, "run-id") ?? `${timestampSlug(startedAtLocal)}-${suiteId}`; + const evidenceRoot = optionString(options, "evidence-dir") ?? join("reports", "evidence", runId); + const reportPath = join("reports", `${runId}.md`); + const manifestPath = join(evidenceRoot, "suite-start.json"); + const handoffPath = join(evidenceRoot, "suite-start.md"); + const cases = suiteCases(root, item).map((caseItem) => { + const caseId = String(caseItem.id); + const caseRunId = `${runId}-${caseId}`; + const evidenceDir = join(evidenceRoot, caseId); + const consoleLog = join(evidenceDir, "console.log"); + const caseReportPath = join("reports", `${caseRunId}.md`); + return { + ...caseItem, + run_id: caseRunId, + evidence_dir: evidenceDir, + plan_command: `bin/lbs test plan ${caseId}`, + start_command: `bin/lbs test start ${caseId}`, + automation_command: caseItem.automation + ? `bin/lbs test run ${caseId} --run-id ${caseRunId} --output ${evidenceDir}` + : "", + report_command: caseItem.automation + ? `bin/lbs test report ${caseId} --since "${startedAtLocal}" --console-log ${consoleLog} --evidence-dir ${evidenceDir} --output ${caseReportPath}` + : `bin/lbs test report ${caseId} --since "${startedAtLocal}" --evidence-dir ${evidenceDir} --output ${caseReportPath}`, + result_command_template: `bin/lbs test result ${caseId} --result --reason "" --evidence-dir ${evidenceDir} --run-id ${caseRunId} --started-at "${startedAtLocal}" --evidence ${Array.isArray(caseItem.evidence_required) ? caseItem.evidence_required.join(",") : ""}`, + }; + }); + + const locator = args.join(" "); + return { + run_id: runId, + started_at: now.toISOString(), + started_at_local: startedAtLocal, + suite, + evidence_root: evidenceRoot, + manifest_path: manifestPath, + handoff_path: handoffPath, + cases, + suite_report_path: reportPath, + plan_command: `bin/lbs suite plan ${locator}`, + report_command: `bin/lbs suite report ${locator} --run-id ${runId} --evidence-dir ${evidenceRoot} --output ${reportPath}`, + evidence_policy: suiteEvidencePolicy(cases), + }; +} + +function writeSuiteStartArtifacts(root: string, start: Record, rendered: string): void { + const evidenceRoot = String(start.evidence_root || ""); + if (!evidenceRoot) return; + + ensureDirectory(root, evidenceRoot, "suite evidence directory"); + for (const caseItem of start.cases as Array>) { + const evidenceDir = String(caseItem.evidence_dir || ""); + if (evidenceDir) ensureDirectory(root, evidenceDir, "case evidence directory"); + } + + const manifestPath = String(start.manifest_path || ""); + if (manifestPath) { + const path = suiteStartPath(root, manifestPath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(start, null, 2)}\n`, "utf8"); + } + + const handoffPath = String(start.handoff_path || ""); + if (handoffPath) { + const path = suiteStartPath(root, handoffPath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, rendered, "utf8"); + } +} + +function renderSuiteStart(start: Record): string { + const suite = start.suite as Record; + const cases = start.cases as Array>; + const lines: string[] = []; + lines.push(`# Suite Start: ${suite.id}`); + lines.push(""); + lines.push(`Run: ${start.run_id}`); + lines.push(`Started: ${start.started_at_local}`); + lines.push(`Title: ${suite.title}`); + lines.push(`Evidence root: ${start.evidence_root}`); + lines.push(""); + lines.push("## Commands"); + lines.push(`- plan: ${start.plan_command}`); + lines.push(`- report: ${start.report_command}`); + lines.push(""); + lines.push("## Cases"); + for (const [index, caseItem] of cases.entries()) { + lines.push(`${index + 1}. ${caseItem.id} [${caseItem.priority}/${caseItem.risk}] ${caseItem.title}`); + lines.push(` - evidence_dir: ${caseItem.evidence_dir}`); + lines.push(` - plan: ${caseItem.plan_command}`); + if (caseItem.automation_command) lines.push(` - automation: ${caseItem.automation_command}`); + else lines.push(` - manual start: ${caseItem.start_command}`); + lines.push(` - report: ${caseItem.report_command}`); + lines.push(` - result template: ${caseItem.result_command_template}`); + } + lines.push(""); + lines.push("## Evidence Policy"); + for (const item of start.evidence_policy as string[]) lines.push(`- ${item}`); + return `${lines.join("\n").trimEnd()}\n`; +} + +export function commandSuiteStart(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findSuite(ctx.root, args); + const start = buildSuiteStart(ctx.root, item, args, options); + const rendered = renderSuiteStart(start); + writeSuiteStartArtifacts(ctx.root, start, rendered); + const content = options.json === true ? `${JSON.stringify(start, null, 2)}\n` : rendered; + writeOrPrint(content, optionString(options, "output")); + return 0; +} + +function suiteRunCaseArgs(root: string, caseItem: Record, headed: boolean): string[] { + const args = [ + lbsScriptPath(), + "--root", + root, + "test", + "run", + String(caseItem.id), + "--run-id", + String(caseItem.run_id), + "--output", + String(caseItem.evidence_dir), + ]; + if (headed) args.push("--headed"); + return args; +} + +function suiteReportExitCode(status: string): number { + if (status === "pass") return 0; + if (status === "blocked" || status === "env_issue" || status === "flaky") return 2; + return 1; +} + +function outputTail(value: string | Buffer | null | undefined): string { + return String(value ?? "").trim().slice(-4000); +} + +function exitStatusFromResultStatus(status: string): number { + if (status === "pass") return 0; + if (status === "blocked" || status === "env_issue" || status === "flaky") return 2; + return 1; +} + +function executionStatusFromExitStatus(status: number): string { + if (status === 0) return "ok"; + if (status === 2) return "classified"; + return "nonzero"; +} + +function executionFromCaseResultFile(caseItem: Record): Record | null { + const resultPath = join(String(caseItem.evidence_dir), "result.json"); + if (!existsSync(resultPath)) return null; + try { + const parsed = JSON.parse(readFileSync(resultPath, "utf8")) as Record; + if ( + parsed.case_id !== caseItem.id || + parsed.run_id !== caseItem.run_id || + typeof parsed.status !== "string" + ) return null; + const exitStatus = exitStatusFromResultStatus(parsed.status); + return { + status: executionStatusFromExitStatus(exitStatus), + exit_status: exitStatus, + reason: typeof parsed.reason === "string" ? parsed.reason : "result.json completed", + result_status: parsed.status, + result_json: resultPath, + }; + } catch { + return null; + } +} + +function executionProblemStatus(executions: Array>): string { + const statuses = executions.map((item) => String(item.status)); + if (statuses.includes("nonzero")) return "fail"; + if (statuses.includes("skipped")) return "incomplete"; + return ""; +} + +function missingReadinessReason(caseItem: Record): string { + const labels: Array<[string, string]> = [ + ["env", "env_readiness"], + ["automation_env", "automation_readiness"], + ["fixture", "fixture_readiness"], + ]; + const missing = labels.flatMap(([label, key]) => { + const value = caseItem[key] as Record | undefined; + if (value?.status !== "missing") return []; + const names = Array.isArray(value.missing) ? value.missing.filter((item): item is string => typeof item === "string") : []; + return [`${label}=${names.length > 0 ? names.join(",") : "missing"}`]; + }); + return missing.length > 0 + ? `case readiness missing (${missing.join(" ")}); rerun with --include-not-ready after fixing or intentionally accepting readiness gaps` + : ""; +} + +export function commandSuiteRun(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findSuite(ctx.root, args); + const start = buildSuiteStart(ctx.root, item, args, options); + const renderedStart = renderSuiteStart(start); + const dryRun = options["dry-run"] === true; + if (!dryRun) writeSuiteStartArtifacts(ctx.root, start, renderedStart); + + const executions = []; + for (const caseItem of start.cases as Array>) { + if (statusOf(caseItem, "manual_readiness") === "manual_check" && options["include-manual-check"] !== true) { + executions.push({ id: caseItem.id, status: "skipped", reason: "case requires manual_check; rerun with --include-manual-check after confirming preconditions" }); + continue; + } + const missingReadiness = missingReadinessReason(caseItem); + if (missingReadiness && options["include-not-ready"] !== true) { + executions.push({ id: caseItem.id, status: "skipped", reason: missingReadiness }); + continue; + } + if (!caseItem.automation) { + executions.push({ id: caseItem.id, status: "skipped", reason: "case has no automation" }); + continue; + } + const runArgs = suiteRunCaseArgs(ctx.root, caseItem, options.headed === true); + if (dryRun) { + executions.push({ id: caseItem.id, status: "planned", reason: "dry-run; case automation not executed", command: [execPath, ...runArgs].join(" ") }); + continue; + } + if (options.json !== true) console.log(`Suite case: ${caseItem.id}`); + const result = spawnSync(execPath, runArgs, { + cwd: ctx.root, + encoding: "utf8", + stdio: options.json === true ? "pipe" : "inherit", + }); + const fileExecution = result.error ? executionFromCaseResultFile(caseItem) : null; + const status = typeof fileExecution?.exit_status === "number" + ? fileExecution.exit_status + : result.error ? 1 : result.status ?? 1; + executions.push({ + id: caseItem.id, + status: fileExecution?.status ?? executionStatusFromExitStatus(status), + exit_status: status, + reason: fileExecution?.reason ?? result.error?.message ?? "", + result_status: fileExecution?.result_status, + result_json: fileExecution?.result_json, + spawn_error: fileExecution && result.error ? result.error.message : undefined, + stdout: outputTail(result.stdout), + stderr: outputTail(result.stderr), + }); + } + + const report = buildSuiteReport(ctx.root, item, { + ...options, + "run-id": String(start.run_id), + "evidence-dir": String(start.evidence_root), + }, executions); + const payload = { + run_id: start.run_id, + evidence_root: start.evidence_root, + executions, + report, + }; + const content = options.json === true + ? `${JSON.stringify(payload, null, 2)}\n` + : renderSuiteReport(report); + writeOrPrint(content, optionString(options, "output") ?? (options.json === true || dryRun ? undefined : String(start.suite_report_path || ""))); + return dryRun ? 0 : suiteReportExitCode(String(report.status)); +} + +function arrayField(data: Record, key: string): string[] { + const value = data[key]; + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +function readCaseResult(evidenceDir: string, caseId: string, expectedRunId: string, requiredEvidence: string[]): Record { + const resultPath = join(evidenceDir, "result.json"); + if (!existsSync(resultPath)) { + return { status: "missing", path: resultPath, reason: "result.json not found" }; + } + try { + const parsed = JSON.parse(readFileSync(resultPath, "utf8")) as Record; + if (parsed.case_id !== caseId) { + return { + status: "invalid", + path: resultPath, + reason: `result.json case_id mismatch: expected ${caseId}, got ${String(parsed.case_id ?? "missing")}`, + }; + } + if (expectedRunId && parsed.run_id !== expectedRunId) { + return { + status: "invalid", + path: resultPath, + reason: `result.json run_id mismatch: expected ${expectedRunId}, got ${String(parsed.run_id ?? "missing")}`, + }; + } + const collected = arrayField(parsed, "evidence_collected"); + const missing = requiredEvidence.filter((item) => !collected.includes(item)); + return { + status: typeof parsed.status === "string" ? parsed.status : "invalid", + path: resultPath, + reason: typeof parsed.reason === "string" ? parsed.reason : "", + started_at_local: typeof parsed.started_at_local === "string" ? parsed.started_at_local : "", + finished_at_local: typeof parsed.finished_at_local === "string" ? parsed.finished_at_local : "", + url: typeof parsed.url === "string" ? parsed.url : "", + evidence_collected: collected, + evidence_required: requiredEvidence, + evidence_missing: missing, + evidence_status: missing.length === 0 ? "complete" : "incomplete", + }; + } catch (error) { + return { status: "invalid", path: resultPath, reason: String(error) }; + } +} + +function suiteStatus(caseResults: Array>): string { + const statuses = caseResults.map((item) => String(item.status)); + if (statuses.length === 0) return "not_run"; + if (statuses.includes("fail") || statuses.includes("invalid")) return "fail"; + if (statuses.includes("missing")) return "incomplete"; + if (caseResults.some((item) => item.status === "pass" && item.evidence_status !== "complete")) return "incomplete"; + if (statuses.every((status) => status === "pass")) return "pass"; + if (statuses.includes("blocked")) return "blocked"; + if (statuses.includes("env_issue")) return "env_issue"; + if (statuses.includes("flaky")) return "flaky"; + return "unknown"; +} + +function buildSuiteReport( + root: string, + item: StructuredItem, + options: Record, + executions: Array> = [], +): Record { + const suite = suiteSummary(item); + const runId = optionString(options, "run-id") ?? ""; + const evidenceRoot = optionString(options, "evidence-dir") ?? (runId ? join("reports", "evidence", runId) : ""); + const cases = suiteCases(root, item).map((caseItem) => { + const caseId = String(caseItem.id); + const expectedCaseRunId = runId ? `${runId}-${caseId}` : ""; + const evidenceDir = evidenceRoot ? join(evidenceRoot, caseId) : ""; + const requiredEvidence = Array.isArray(caseItem.evidence_required) ? caseItem.evidence_required : []; + const result = evidenceDir + ? readCaseResult(evidenceDir, caseId, expectedCaseRunId, requiredEvidence) + : { status: "missing", path: "", reason: "Set --evidence-dir or --run-id to locate case result.json files" }; + return { + ...caseItem, + evidence_dir: evidenceDir, + result, + }; + }); + const counts: Record = {}; + for (const item of cases) { + const status = String((item.result as Record).status); + counts[status] = (counts[status] ?? 0) + 1; + } + + const resultStatus = suiteStatus(cases.map((item) => item.result as Record)); + const executionStatus = executionProblemStatus(executions); + return { + generated_at: new Date().toISOString(), + run_id: runId, + suite, + evidence_root: evidenceRoot, + status: executionStatus || resultStatus, + counts, + cases, + execution_status: executionStatus || "ok", + decision_policy: [ + "pass requires every case result to be pass.", + "suite run pass also requires every attempted execution to finish ok.", + "blocked and env_issue are not product pass.", + "pass results missing required evidence keep the suite incomplete.", + "result.json must match the expected case_id and suite case run_id.", + "missing or invalid result.json means the suite is incomplete or failed to collect evidence.", + ], + }; +} + +function renderSuiteReport(report: Record): string { + const suite = report.suite as Record; + const cases = report.cases as Array>; + const counts = report.counts as Record; + const lines: string[] = []; + lines.push(`# Suite Report: ${suite.id}`); + lines.push(""); + lines.push(`Generated: ${report.generated_at}`); + if (report.run_id) lines.push(`Run: ${report.run_id}`); + lines.push(`Title: ${suite.title}`); + lines.push(`Status: ${report.status}`); + lines.push(`Evidence root: ${report.evidence_root || "not provided"}`); + lines.push(""); + lines.push("## Counts"); + for (const key of Object.keys(counts).sort()) lines.push(`- ${key}: ${counts[key]}`); + if (Object.keys(counts).length === 0) lines.push("- None."); + lines.push(""); + lines.push("## Cases"); + for (const caseItem of cases) { + const result = caseItem.result as Record; + lines.push(`- ${caseItem.id}: ${result.status} - ${result.reason || "no reason"}`); + if (Array.isArray(result.evidence_missing) && result.evidence_missing.length > 0) { + lines.push(` evidence_missing: ${result.evidence_missing.join(", ")}`); + } + if (caseItem.evidence_dir) lines.push(` evidence_dir: ${caseItem.evidence_dir}`); + if (result.path) lines.push(` result_json: ${result.path}`); + } + lines.push(""); + lines.push("## Decision Policy"); + for (const item of report.decision_policy as string[]) lines.push(`- ${item}`); + return `${lines.join("\n").trimEnd()}\n`; +} + +export function commandSuiteReport(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findSuite(ctx.root, args); + const report = buildSuiteReport(ctx.root, item, options); + const content = options.json === true ? `${JSON.stringify(report, null, 2)}\n` : renderSuiteReport(report); + writeOrPrint(content, optionString(options, "output")); + return 0; +} diff --git a/skills/src/commands/test.ts b/skills/src/commands/test.ts new file mode 100644 index 000000000..67ddc3122 --- /dev/null +++ b/skills/src/commands/test.ts @@ -0,0 +1,1505 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { env as processEnv, execPath } from "node:process"; +import type { CommandContext, StructuredItem } from "../types.ts"; +import { parseOptions, usage } from "../cli.ts"; +import { caseEvidenceValues, testResultStatusValues } from "../constants.ts"; +import { boolValue, findStructuredItem, listValue, loadEnv, loadStructuredItems, scalar } from "../fs.ts"; +import { splitEnvAnyGroup } from "../env-groups.ts"; +import { + readAutomationResultEvidence, + renderLogFinding, + renderLogSuccessSignal, + scanStructuredLogSources, + type AutomationResultEvidence, + type LogFinding, + type LogGuardResult, + type LogSuccessSignal, +} from "../log-guard.ts"; +import { + automationEnvDefaults, + caseAutomationReadiness, + caseEnvReadiness, + caseFixtureReadiness, + caseManualReadiness, + redactEnvValue, + resolvedAutomationEnvOverrides, + runtimeEnv, + type AutomationReadiness, + type EnvReadiness, + type FixtureReadiness, + type ManualReadiness, +} from "../readiness.ts"; +import { + lbsScriptPath, + parseSetupAutomationEntry, + setupAutomationEntries, + setupAutomationEvidenceName, + setupAutomationScriptPath, +} from "../setup-automation.ts"; + +type TroubleshootingSummary = { + id: string; + title: string; + patterns: string[]; + verification: string; +}; + +type TestPlan = { + id: string; + title: string; + mode: string; + principle: string; + env: Record; + env_readiness: EnvReadiness; + automation_readiness: AutomationReadiness; + fixture_readiness: FixtureReadiness; + manual_readiness: ManualReadiness; + required_skills: string[]; + preconditions: string[]; + setup: string[]; + setup_automation: string[]; + setup_provides_env: string[]; + cleanup: string[]; + steps: string[]; + checks: string[]; + diagnostics: string[]; + visual_checks: string[]; + evidence_required: string[]; + success_patterns: string[]; + failure_patterns: string[]; + troubleshooting: TroubleshootingSummary[]; + report_template: Record; +}; + +type TestStart = { + run_id: string; + started_at: string; + started_at_local: string; + case: Record; + environment: Record; + required_skills: string[]; + preconditions: string[]; + setup: string[]; + setup_automation: string[]; + setup_provides_env: string[]; + cleanup: string[]; + steps: string[]; + checks: string[]; + success_patterns: string[]; + failure_patterns: string[]; + evidence_required: string[]; + automation?: { + script: string; + command: string; + evidence_dir: string; + }; + recommended_report_path: string; + plan_command: string; + report_command: string; + result_command_template: string; + evidence_checklist: string[]; +}; + +type TestAutomationRun = { + run_id: string; + started_at: string; + started_at_local: string; + case: Record; + setup_automation: SetupAutomation[]; + automation: { + script: string; + script_path: string; + exists: boolean; + required_env: string[]; + evidence_dir: string; + console_log: string; + network_log: string; + screenshot: string; + automation_result_json: string; + result_json: string; + command: string; + report_command: string; + env_defaults: Record; + env_aliases: Array<{ + target: string; + source: string; + configured: boolean; + }>; + pipeline_env_required: boolean; + }; +}; + +type SetupAutomation = { + entry: string; + kind: "case" | "node"; + target: string; + args: string[]; + command: string; + dry_run_command: string; + evidence_dir: string; + exists: boolean; +}; + +type TestResultRecord = { + source: "final"; + case_id: string; + run_id: string; + written_at: string; + written_at_local: string; + started_at: string; + started_at_local: string; + finished_at: string; + finished_at_local: string; + status: string; + reason: string; + url: string; + browser_path: string; + evidence_dir: string; + evidence_collected: string[]; + evidence_required: string[]; + evidence_missing: string[]; + evidence_status: "complete" | "incomplete"; + report_path: string; + notes: string; +}; + +type ManualEvidenceTemplate = { + result: string; + [key: string]: string; +}; + +type TestReport = { + generated_at: string; + case: Record; + result_options: string[]; + automation_result: AutomationResultEvidence; + manual_evidence: ManualEvidenceTemplate; + environment: Record; + required_skills: string[]; + steps: string[]; + checks: string[]; + diagnostics: string[]; + evidence_required: string[]; + success_patterns: string[]; + failure_patterns: string[]; + expected_failures: string[]; + troubleshooting: TroubleshootingSummary[]; + log_guard: LogGuardResult; +}; + +type TestRecommendation = { + id: string; + reason: string; +}; + +type TestRecommendReport = { + generated_at: string; + changed_files: string[]; + recommendations: TestRecommendation[]; + commands: string[]; + notes: string[]; +}; + +function relatedTroubleshooting(root: string, item: StructuredItem): StructuredItem[] { + return listValue(item.fields, "troubleshooting") + .map((id) => { + try { + return findStructuredItem(root, "troubleshooting", id); + } catch { + return null; + } + }) + .filter((entry): entry is StructuredItem => entry !== null); +} + +function findCase(root: string, args: string[]): StructuredItem { + if (args.length < 1 || args.length > 2) usage(); + + return args.length === 1 + ? findStructuredItem(root, "cases", args[0]) + : findStructuredItem(root, "cases", args[0], args[1]); +} + +function caseSummary(item: StructuredItem): Record { + return { + skill: item.skill, + id: scalar(item.fields, "id"), + title: scalar(item.fields, "title"), + mode: scalar(item.fields, "mode"), + area: scalar(item.fields, "area"), + type: scalar(item.fields, "type"), + priority: scalar(item.fields, "priority"), + risk: scalar(item.fields, "risk"), + ci_eligible: boolValue(item.fields, "ci_eligible") ?? false, + tags: listValue(item.fields, "tags"), + }; +} + +function caseMode(item: StructuredItem): string { + return scalar(item.fields, "mode") || "agent-browser"; +} + +function isProbeMode(mode: string): boolean { + return mode === "probe"; +} + +function modePrinciple(mode: string): string { + return isProbeMode(mode) + ? "Run the declared probe steps and collect the required evidence. Browser/UI interaction is not required unless the case steps explicitly call for it." + : "Use browser/UI interaction as the primary QA path. API/curl/log checks are diagnostic only and cannot make a UI case pass by themselves."; +} + +function stepHeading(mode: string): string { + return isProbeMode(mode) ? "Probe Steps" : "Browser Steps"; +} + +function visualChecks(mode: string): string[] { + if (isProbeMode(mode)) return []; + return [ + "If the active agent has screenshot/vision capability, capture before/after screenshots.", + "Look for blank pages, overlapping text, hidden primary actions, error toasts, or broken layout.", + "If no visual model is available, use DOM/accessibility snapshots and console output instead.", + ]; +} + +function reportTemplate(mode: string): Record { + if (isProbeMode(mode)) { + return { + result: "pass | fail | blocked | env_issue | flaky", + target_tested: "Probe target, endpoint, file, command, or service actually checked", + execution_path: "automation script | shell command | direct API | other", + probe_result: "What the probe observed", + metrics_or_artifacts: "Metrics, logs, filesystem artifacts, traces, or profiles collected", + diagnostics: "Extra diagnostics used, if any", + matched_troubleshooting: "Troubleshooting ids matched, if any", + assets_to_update: "New case/reference/troubleshooting entries to add", + }; + } + + return { + result: "pass | fail | blocked | env_issue | flaky", + url_tested: "LANGBOT_FRONTEND_URL actually opened", + browser_path: "Computer Use | Playwright MCP | other", + ui_result: "What the user-visible UI showed", + console_errors: "Unexpected browser console errors, if any", + backend_logs: "Relevant backend log lines, if checked", + screenshots: "Screenshot paths or skipped reason", + diagnostics: "API/curl/log diagnostics used, if any", + matched_troubleshooting: "Troubleshooting ids matched, if any", + assets_to_update: "New case/reference/troubleshooting entries to add", + }; +} + +function evidenceChecklist(mode: string): string[] { + if (isProbeMode(mode)) { + return [ + "Execute the declared probe steps or automation script.", + "Store required logs, API diagnostics, filesystem artifacts, or other evidence in the evidence directory.", + "After execution, run the report command to scan logs from the start timestamp.", + "Write a final result.json with the result command only after required evidence has been collected.", + "Mark the final result as pass, fail, blocked, env_issue, or flaky in the generated report.", + ]; + } + + return [ + "Open the configured LangBot WebUI and execute the browser steps.", + "Capture screenshot paths when screenshot/vision tooling is available.", + "Record unexpected console errors and failed network requests without pasting secrets.", + "After browser execution, run the report command to scan logs from the start timestamp.", + "Write a final result.json with the result command only after required evidence has been collected.", + "Mark the final result as pass, fail, blocked, env_issue, or flaky in the generated report.", + ]; +} + +function manualEvidenceTemplate(mode: string): ManualEvidenceTemplate { + if (isProbeMode(mode)) { + return { + result: "pass | fail | blocked | env_issue | flaky", + target_tested: "TODO: probe target, endpoint, file, command, or service actually checked", + execution_path: "TODO: automation script | shell command | direct API | other", + probe_result: "TODO: observed probe result", + metrics_or_artifacts: "TODO: metrics, logs, filesystem artifacts, traces, or profiles collected", + diagnostics: "TODO: additional diagnostics used, if any", + matched_troubleshooting: "TODO: troubleshooting ids matched, if any", + assets_to_update: "TODO: case/reference/troubleshooting updates to make", + }; + } + + return { + result: "pass | fail | blocked | env_issue | flaky", + url_tested: "LANGBOT_FRONTEND_URL actually opened", + browser_path: "Computer Use | Playwright MCP | direct Playwright | other", + ui_result: "TODO: user-visible result", + console_errors: "TODO: unexpected browser console errors or none", + network_symptoms: "TODO: failed requests, websocket issues, or none", + backend_logs: "TODO: relevant backend log lines or skipped reason", + frontend_logs: "TODO: relevant frontend dev-server log lines or skipped reason", + screenshots: "TODO: screenshot paths or skipped reason", + diagnostics: "TODO: API/curl/log diagnostics used, if any", + matched_troubleshooting: "TODO: troubleshooting ids matched, if any", + assets_to_update: "TODO: case/reference/troubleshooting updates to make", + }; +} + +function envSummary(item: StructuredItem, env: Record): Record { + const keys = [ + ...listValue(item.fields, "env"), + ...listValue(item.fields, "env_any").flatMap(splitEnvAnyGroup), + ]; + return Object.fromEntries(Array.from(new Set(keys)).map((key) => [key, redactEnvValue(key, env[key] ?? "")])); +} + +function buildPlan(root: string, item: StructuredItem): TestPlan { + const env = runtimeEnv(root); + const troubles = relatedTroubleshooting(root, item); + const id = scalar(item.fields, "id"); + const mode = caseMode(item); + return { + id, + title: scalar(item.fields, "title"), + mode, + principle: modePrinciple(mode), + env: envSummary(item, env), + env_readiness: caseEnvReadiness(item, env), + automation_readiness: caseAutomationReadiness(item, env), + fixture_readiness: caseFixtureReadiness(root, id), + manual_readiness: caseManualReadiness(item), + required_skills: listValue(item.fields, "skills"), + preconditions: listValue(item.fields, "preconditions"), + setup: listValue(item.fields, "setup"), + setup_automation: setupAutomationEntries(item), + setup_provides_env: listValue(item.fields, "setup_provides_env"), + cleanup: listValue(item.fields, "cleanup"), + steps: listValue(item.fields, "steps"), + checks: listValue(item.fields, "checks"), + diagnostics: listValue(item.fields, "diagnostics"), + visual_checks: visualChecks(mode), + evidence_required: listValue(item.fields, "evidence_required"), + success_patterns: listValue(item.fields, "success_patterns"), + failure_patterns: listValue(item.fields, "failure_patterns"), + troubleshooting: troubles.map((entry) => ({ + id: scalar(entry.fields, "id"), + title: scalar(entry.fields, "title"), + patterns: listValue(entry.fields, "patterns"), + verification: scalar(entry.fields, "verification"), + })), + report_template: reportTemplate(mode), + }; +} + +export function commandTestPlan(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findCase(ctx.root, args); + const plan = buildPlan(ctx.root, item); + + if (options.json === true) { + console.log(JSON.stringify(plan, null, 2)); + return 0; + } + + console.log(`# Test Plan: ${plan.id}`); + console.log(""); + console.log(`Title: ${plan.title}`); + console.log(`Mode: ${plan.mode}`); + console.log(""); + console.log("## Principle"); + console.log(plan.principle); + console.log(""); + console.log("## Environment"); + for (const [key, value] of Object.entries(plan.env)) console.log(`- ${key}=${value}`); + if (plan.env_readiness.missing.length > 0) console.log(`- missing: ${plan.env_readiness.missing.join(", ")}`); + console.log(""); + console.log("## Automation Readiness"); + console.log(`- status: ${plan.automation_readiness.status}`); + if (plan.automation_readiness.script) console.log(`- script: ${plan.automation_readiness.script}`); + if (plan.automation_readiness.pipeline_env_required) console.log("- pipeline env: case-specific required"); + if (plan.automation_readiness.missing.length > 0) console.log(`- missing: ${plan.automation_readiness.missing.join(", ")}`); + if (plan.automation_readiness.defaulted.length > 0) console.log(`- case defaults: ${plan.automation_readiness.defaulted.join(", ")}`); + for (const alias of plan.automation_readiness.env_aliases) { + console.log(`- alias: ${alias.target} <- ${alias.source} (${alias.configured ? "configured" : "missing"})`); + } + console.log(""); + console.log("## Fixture Readiness"); + console.log(`- status: ${plan.fixture_readiness.status}`); + for (const fixture of plan.fixture_readiness.required) { + console.log(`- ${fixture.id}: ${fixture.exists ? "present" : "missing"} (${fixture.path})`); + } + console.log(""); + console.log("## Manual Readiness"); + console.log(`- status: ${plan.manual_readiness.status}`); + if (plan.preconditions.length === 0) console.log("- preconditions: none declared"); + for (const precondition of plan.preconditions) console.log(`- precondition: ${precondition}`); + if (plan.setup.length > 0) for (const item of plan.setup) console.log(`- setup: ${item}`); + if (plan.setup_automation.length > 0) { + for (const item of plan.setup_automation) console.log(`- setup automation: ${item}`); + } + if (plan.setup_provides_env.length > 0) console.log(`- setup provides env: ${plan.setup_provides_env.join(", ")}`); + if (plan.cleanup.length > 0) for (const item of plan.cleanup) console.log(`- cleanup: ${item}`); + console.log(""); + console.log("## Required Skills"); + for (const skill of plan.required_skills) console.log(`- ${skill}`); + console.log(""); + console.log(`## ${stepHeading(plan.mode)}`); + for (const [index, step] of plan.steps.entries()) console.log(`${index + 1}. ${step}`); + console.log(""); + console.log("## Checks"); + for (const check of plan.checks) console.log(`- ${check}`); + console.log(""); + console.log("## Diagnostics"); + if (plan.diagnostics.length === 0) console.log("- Optional: use API/curl/logs only to diagnose failures."); + for (const diagnostic of plan.diagnostics) console.log(`- ${diagnostic}`); + console.log(""); + if (plan.visual_checks.length > 0) { + console.log("## Visual Checks"); + for (const check of plan.visual_checks) console.log(`- ${check}`); + console.log(""); + } + console.log("## Required Evidence"); + if (plan.evidence_required.length === 0) console.log("- None declared."); + for (const evidence of plan.evidence_required) console.log(`- ${evidence}`); + console.log(""); + console.log("## Success Signals"); + if (plan.success_patterns.length === 0) console.log("- None declared."); + for (const pattern of plan.success_patterns) console.log(`- ${pattern}`); + console.log(""); + console.log("## Failure Signals"); + if (plan.failure_patterns.length === 0) console.log("- None declared."); + for (const pattern of plan.failure_patterns) console.log(`- ${pattern}`); + console.log(""); + console.log("## Troubleshooting"); + for (const entry of plan.troubleshooting) { + console.log(`- ${entry.id}: ${entry.title}`); + for (const pattern of entry.patterns) console.log(` pattern: ${pattern}`); + } + console.log(""); + console.log("## Report Template"); + for (const [key, value] of Object.entries(plan.report_template)) console.log(`- ${key}: ${value}`); + return 0; +} + +function normalizeChangedPath(path: string): string { + return path.replace(/\\/g, "/").replace(/^\.\//, ""); +} + +function isChangedFilePath(path: string): boolean { + return Boolean(path) && !path.endsWith("/") && !path.startsWith("--- ") && !path.startsWith("+++ "); +} + +function existingCaseIds(root: string): Set { + return new Set(loadStructuredItems(root, "cases").map((item) => scalar(item.fields, "id"))); +} + +function addRecommendation( + output: TestRecommendation[], + existing: Set, + id: string, + reason: string, +): void { + if (!existing.has(id) || output.some((item) => item.id === id)) return; + output.push({ id, reason }); +} + +function changedFilesFromGit(repo: string, prefix: string): string[] { + if (!existsSync(repo)) return []; + const argsList = [ + ["diff", "--name-only", "HEAD"], + ["status", "--short"], + ]; + const files: string[] = []; + for (const args of argsList) { + const result = spawnSync("git", args, { + cwd: repo, + encoding: "utf8", + }); + if (result.status !== 0) continue; + for (const raw of result.stdout.split(/\r?\n/)) { + if (!raw.trim()) continue; + const file = args[0] === "status" + ? raw.slice(3).trim().split(/\s+->\s+/).pop() ?? "" + : raw.trim(); + if (isChangedFilePath(file)) files.push(`${prefix}/${normalizeChangedPath(file)}`); + } + } + return files; +} + +function repoCandidates(root: string, env: Record): Array<{ path: string; prefix: string }> { + return [ + { path: env.LANGBOT_REPO || resolve(root, "../LangBot"), prefix: "LangBot" }, + { path: env.LANGBOT_PLUGIN_SDK_REPO || resolve(root, "../langbot-plugin-sdk"), prefix: "langbot-plugin-sdk" }, + { path: env.LANGBOT_AGENT_RUNNER_REPO || resolve(root, "../langbot-agent-runner"), prefix: "langbot-agent-runner" }, + { path: env.LANGBOT_LOCAL_AGENT_REPO || resolve(root, "../langbot-local-agent"), prefix: "langbot-local-agent" }, + ]; +} + +function repeatedOptionValues(args: string[], key: string): string[] { + const values: string[] = []; + for (let i = 0; i < args.length; i += 1) { + if (args[i] !== `--${key}`) continue; + const value = args[i + 1]; + if (value && !value.startsWith("--")) values.push(value); + } + return values; +} + +function changedFiles(root: string, explicitFiles: string[]): string[] { + const explicit = explicitFiles.map(normalizeChangedPath); + if (explicit.length > 0) return Array.from(new Set(explicit)); + + const env = runtimeEnv(root); + const files = repoCandidates(root, env).flatMap((repo) => changedFilesFromGit(repo.path, repo.prefix)); + return Array.from(new Set(files)).sort(); +} + +function buildRecommendations(root: string, files: string[]): TestRecommendation[] { + const existing = existingCaseIds(root); + const recommendations: TestRecommendation[] = []; + const text = files.map(normalizeChangedPath); + const has = (pattern: RegExp) => text.some((file) => pattern.test(file)); + + if (has(/(^|\/)(result_normalizer|orchestrator|descriptor|errors)\.py$/) || has(/agent_runner\/result\.py$/)) { + addRecommendation(recommendations, existing, "agent-runner-fixture-contract", "Deterministic AgentRunner fixture contract should still execute."); + addRecommendation(recommendations, existing, "agent-runner-behavior-matrix", "AgentRunner result/orchestration contract changed."); + } + if (has(/fixtures\/plugins\/qa-agent-runner|components\/agent_runner|manifest\.ya?ml$/)) { + addRecommendation(recommendations, existing, "agent-runner-fixture-contract", "AgentRunner fixture or runner manifest changed."); + addRecommendation(recommendations, existing, "agent-runner-live-install", "AgentRunner plugin package should still install and register."); + addRecommendation(recommendations, existing, "agent-runner-qa-debug-chat", "Installed QA AgentRunner should still execute through Debug Chat."); + } + if (has(/fixtures\/plugins\/qa-plugin-smoke|qa_plugin_|qa-plugin-smoke/i)) { + addRecommendation(recommendations, existing, "qa-plugin-smoke-live-install", "QA plugin smoke fixture should install and expose tools."); + } + if (has(/(run_ledger|agent_run\.py|run_ledger\.py|alembic.*agent_run|test_run_ledger)/i)) { + addRecommendation(recommendations, existing, "agent-runner-ledger-invariants", "Run ledger schema/status code changed."); + addRecommendation(recommendations, existing, "agent-runner-ledger-stress", "Run ledger queue/claim behavior changed."); + addRecommendation(recommendations, existing, "agent-runner-ledger-contention", "Run ledger claim behavior changed; check local write contention."); + addRecommendation(recommendations, existing, "agent-runner-async-db-readiness", "Async DB readiness gates ledger concurrency probes."); + addRecommendation(recommendations, existing, "agent-runner-ledger-concurrency", "Run ledger concurrency/auth tests are relevant."); + } + if (has(/(plugin\/handler|agent_run_api|history_event_api|state_api|pull_api|runtime\/plugin|test_mgr_agent_runner|test_pull_api_handlers)/)) { + addRecommendation(recommendations, existing, "agent-runner-runtime-chaos", "SDK/runtime or Host action handling changed."); + } + if (has(/(LangBot\/web\/|^web\/|control-plane|frontend|\/page|\/pages)/)) { + addRecommendation(recommendations, existing, "agent-runner-release-preflight", "UI/control-plane surface changed; preflight catches wrong live target."); + addRecommendation(recommendations, existing, "webui-login-state", "Browser session must still reach LangBot WebUI."); + } + if (has(/(local-agent|context|compaction|rag|tool|mcp|multimodal)/i)) { + addRecommendation(recommendations, existing, "qa-plugin-smoke-live-install", "Tool-loop checks depend on the QA plugin smoke fixture."); + addRecommendation(recommendations, existing, "local-agent-basic-debug-chat", "Local-agent user path may be affected."); + addRecommendation(recommendations, existing, "local-agent-plugin-tool-call-debug-chat", "Tool-loop changes need browser evidence."); + } + if (has(/(^|\/)(acp|claude|codex)(\/|-)|langbot-agent-runner\//i)) { + addRecommendation(recommendations, existing, "acp-agent-runner-debug-chat", "External AgentRunner path may be affected."); + } + if (recommendations.length === 0) { + addRecommendation(recommendations, existing, "agent-runner-release-preflight", "No narrow AgentRunner rule matched; start with preflight if this branch touches runner behavior."); + } + return recommendations; +} + +function buildRecommendReport(root: string, explicitFiles: string[]): TestRecommendReport { + const files = changedFiles(root, explicitFiles); + const recommendations = buildRecommendations(root, files); + return { + generated_at: new Date().toISOString(), + changed_files: files, + recommendations, + commands: recommendations.flatMap((item) => [ + `bin/lbs test plan ${item.id}`, + `bin/lbs test run ${item.id} --dry-run`, + ]), + notes: [ + "Run probe cases before browser cases.", + "Remove --dry-run only after readiness and manual_check preconditions are confirmed.", + "Treat blocked/env_issue separately from product fail.", + "Browser cases still need required UI evidence before pass.", + ], + }; +} + +function renderRecommendReport(report: TestRecommendReport): string { + const lines: string[] = []; + lines.push("# Test Recommendations"); + lines.push(""); + lines.push(`Generated: ${report.generated_at}`); + lines.push(""); + lines.push("## Changed Files"); + if (report.changed_files.length === 0) lines.push("- None detected. Pass --file to recommend from explicit paths."); + else for (const file of report.changed_files) lines.push(`- ${file}`); + lines.push(""); + lines.push("## Recommended Cases"); + if (report.recommendations.length === 0) lines.push("- None."); + for (const item of report.recommendations) { + lines.push(`- ${item.id}: ${item.reason}`); + } + lines.push(""); + lines.push("## Commands"); + for (const command of report.commands) lines.push(`- ${command}`); + lines.push(""); + lines.push("## Notes"); + for (const note of report.notes) lines.push(`- ${note}`); + return `${lines.join("\n").trimEnd()}\n`; +} + +export function commandTestRecommend(ctx: CommandContext): number { + const { options } = parseOptions(ctx.args.slice(2)); + const report = buildRecommendReport(ctx.root, repeatedOptionValues(ctx.args.slice(2), "file")); + if (options.json === true) console.log(JSON.stringify(report, null, 2)); + else console.log(renderRecommendReport(report).trimEnd()); + return 0; +} + +function pad2(value: number): string { + return String(value).padStart(2, "0"); +} + +function pad3(value: number): string { + return String(value).padStart(3, "0"); +} + +function localIsoWithOffset(date: Date): string { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absoluteOffset = Math.abs(offsetMinutes); + return [ + `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`, + `T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${pad3(date.getMilliseconds())}`, + `${sign}${pad2(Math.floor(absoluteOffset / 60))}:${pad2(absoluteOffset % 60)}`, + ].join(""); +} + +function timestampSlug(localIso: string): string { + return localIso + .replace(/T/, "-") + .replace(/[.:+]/g, "-") + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function caseLocator(args: string[]): string { + return args.join(" "); +} + +function automationScript(item: StructuredItem): string { + return scalar(item.fields, "automation"); +} + +function setupCaseExists(root: string, target: string): boolean { + return loadStructuredItems(root, "cases").some((item) => scalar(item.fields, "id") === target); +} + +function setupAutomation(root: string, item: StructuredItem, runId: string, evidenceRoot: string): SetupAutomation[] { + return setupAutomationEntries(item).map((entry, index) => { + const spec = parseSetupAutomationEntry(entry); + const evidenceDir = join("setup", setupAutomationEvidenceName(index, spec)); + const fullEvidenceDir = join(evidenceRoot, evidenceDir); + const command = spec.kind === "case" + ? `bin/lbs test run ${spec.target} --run-id ${runId}-${spec.target} --output ${fullEvidenceDir}` + : `node ${spec.target}${spec.args.length > 0 ? ` ${spec.args.join(" ")}` : ""}`; + const dryRunCommand = spec.kind === "case" ? `${command} --dry-run` : ""; + return { + entry, + kind: spec.kind, + target: spec.target, + args: spec.args, + command, + dry_run_command: dryRunCommand, + evidence_dir: fullEvidenceDir, + exists: spec.kind === "case" ? setupCaseExists(root, spec.target) : existsSync(setupAutomationScriptPath(root, spec)), + }; + }); +} + +function isoFromDateInput(input: string): string { + const parsed = Date.parse(input); + return Number.isNaN(parsed) ? "" : new Date(parsed).toISOString(); +} + +function commaList(value: string | undefined): string[] { + if (!value) return []; + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function buildTestResult( + root: string, + item: StructuredItem, + options: Record, +): { result?: TestResultRecord; errors: string[] } { + const errors: string[] = []; + const status = typeof options.result === "string" ? options.result : ""; + const reason = typeof options.reason === "string" ? options.reason : ""; + const evidenceDir = typeof options["evidence-dir"] === "string" ? options["evidence-dir"] : ""; + const now = new Date(); + const writtenAtLocal = localIsoWithOffset(now); + const startedAtLocal = typeof options["started-at"] === "string" ? options["started-at"] : writtenAtLocal; + const finishedAtLocal = typeof options["finished-at"] === "string" ? options["finished-at"] : writtenAtLocal; + const startedAt = isoFromDateInput(startedAtLocal); + const finishedAt = isoFromDateInput(finishedAtLocal); + const evidenceCollected = commaList(typeof options.evidence === "string" ? options.evidence : undefined); + const evidenceRequired = listValue(item.fields, "evidence_required"); + const evidenceMissing = evidenceRequired.filter((value) => !evidenceCollected.includes(value)); + + if (!status) errors.push("--result is required"); + else if (!testResultStatusValues.includes(status)) { + errors.push(`--result must be one of ${testResultStatusValues.join(", ")}`); + } + if (!reason) errors.push("--reason is required"); + if (!evidenceDir) errors.push("--evidence-dir is required"); + if (!startedAt) errors.push(`--started-at is not a valid date/time: ${startedAtLocal}`); + if (!finishedAt) errors.push(`--finished-at is not a valid date/time: ${finishedAtLocal}`); + + const allowedEvidence = new Set(caseEvidenceValues); + for (const value of evidenceCollected) { + if (!allowedEvidence.has(value)) errors.push(`--evidence contains unsupported value '${value}'`); + } + if (status === "pass" && evidenceMissing.length > 0) { + errors.push(`pass result is missing required evidence: ${evidenceMissing.join(", ")}`); + } + + if (errors.length > 0) return { errors }; + + const resolvedEvidenceDir = resolve(evidenceDir); + return { + errors, + result: { + source: "final", + case_id: scalar(item.fields, "id"), + run_id: typeof options["run-id"] === "string" ? options["run-id"] : resolvedEvidenceDir.split(/[\\/]/).pop() ?? "", + written_at: now.toISOString(), + written_at_local: writtenAtLocal, + started_at: startedAt, + started_at_local: startedAtLocal, + finished_at: finishedAt, + finished_at_local: finishedAtLocal, + status, + reason, + url: typeof options.url === "string" ? options.url : "", + browser_path: typeof options["browser-path"] === "string" ? options["browser-path"] : "", + evidence_dir: evidenceDir, + evidence_collected: evidenceCollected, + evidence_required: evidenceRequired, + evidence_missing: evidenceMissing, + evidence_status: evidenceMissing.length === 0 ? "complete" : "incomplete", + report_path: typeof options.report === "string" ? options.report : "", + notes: typeof options.notes === "string" ? options.notes : "", + }, + }; +} + +function renderTestResult(result: TestResultRecord): string { + const lines: string[] = []; + lines.push(`# Test Result: ${result.case_id}`); + lines.push(""); + lines.push(`Run: ${result.run_id}`); + lines.push(`Status: ${result.status}`); + lines.push(`Reason: ${result.reason}`); + lines.push(`Evidence dir: ${result.evidence_dir}`); + lines.push(`Evidence status: ${result.evidence_status}`); + if (result.evidence_missing.length > 0) lines.push(`Evidence missing: ${result.evidence_missing.join(", ")}`); + if (result.url) lines.push(`URL: ${result.url}`); + if (result.browser_path) lines.push(`Browser path: ${result.browser_path}`); + if (result.report_path) lines.push(`Report: ${result.report_path}`); + lines.push(""); + lines.push("## Evidence Collected"); + if (result.evidence_collected.length === 0) lines.push("- None declared."); + else for (const value of result.evidence_collected) lines.push(`- ${value}`); + return `${lines.join("\n").trimEnd()}\n`; +} + +function buildStart(root: string, item: StructuredItem, args: string[]): TestStart { + const now = new Date(); + const startedAtLocal = localIsoWithOffset(now); + const id = scalar(item.fields, "id"); + const mode = caseMode(item); + const runId = `${timestampSlug(startedAtLocal)}-${id}`; + const recommendedReportPath = join("reports", `${runId}.md`); + const evidenceDir = join("reports", "evidence", runId); + const locator = caseLocator(args); + const script = automationScript(item); + const automationCommand = script + ? `bin/lbs test run ${locator} --run-id ${runId} --output ${evidenceDir}` + : undefined; + const consoleLog = join(evidenceDir, "console.log"); + const reportCommand = script + ? `bin/lbs test report ${locator} --since "${startedAtLocal}" --console-log ${consoleLog} --evidence-dir ${evidenceDir} --output ${recommendedReportPath}` + : `bin/lbs test report ${locator} --since "${startedAtLocal}" --evidence-dir ${evidenceDir} --output ${recommendedReportPath}`; + const resultCommandTemplate = `bin/lbs test result ${locator} --result --reason "" --evidence-dir ${evidenceDir} --started-at "${startedAtLocal}" --evidence ${listValue(item.fields, "evidence_required").join(",")}`; + + return { + run_id: runId, + started_at: now.toISOString(), + started_at_local: startedAtLocal, + case: caseSummary(item), + environment: envSummary(item, { ...loadEnv(root), ...processEnv }), + required_skills: listValue(item.fields, "skills"), + preconditions: listValue(item.fields, "preconditions"), + setup: listValue(item.fields, "setup"), + setup_automation: setupAutomationEntries(item), + setup_provides_env: listValue(item.fields, "setup_provides_env"), + cleanup: listValue(item.fields, "cleanup"), + steps: listValue(item.fields, "steps"), + checks: listValue(item.fields, "checks"), + evidence_required: listValue(item.fields, "evidence_required"), + success_patterns: listValue(item.fields, "success_patterns"), + failure_patterns: listValue(item.fields, "failure_patterns"), + automation: script + ? { + script, + command: automationCommand ?? "", + evidence_dir: evidenceDir, + } + : undefined, + recommended_report_path: recommendedReportPath, + plan_command: `bin/lbs test plan ${locator}`, + report_command: reportCommand, + result_command_template: resultCommandTemplate, + evidence_checklist: evidenceChecklist(mode), + }; +} + +function renderMarkdownStart(start: TestStart): string { + const lines: string[] = []; + const reportCase = start.case; + + lines.push(`# Test Start: ${reportCase.id}`); + lines.push(""); + lines.push(`Run: ${start.run_id}`); + lines.push(`Started: ${start.started_at_local}`); + lines.push(`Title: ${reportCase.title}`); + lines.push(`Skill: ${reportCase.skill}`); + lines.push(""); + lines.push("## Commands"); + lines.push(`- plan: ${start.plan_command}`); + if (start.automation) lines.push(`- automation: ${start.automation.command}`); + lines.push(`- report: ${start.report_command}`); + lines.push(`- result template: ${start.result_command_template}`); + lines.push(""); + lines.push("## Evidence Checklist"); + for (const item of start.evidence_checklist) lines.push(`- ${item}`); + lines.push(""); + lines.push(...renderLines("Required Evidence", start.evidence_required)); + lines.push(""); + lines.push(...renderLines("Preconditions", start.preconditions)); + lines.push(...renderLines("Setup", start.setup)); + lines.push(...renderLines("Setup Automation", start.setup_automation)); + lines.push(...renderLines("Setup Provides Env", start.setup_provides_env)); + lines.push(...renderLines("Cleanup", start.cleanup)); + lines.push(`## ${stepHeading(String(reportCase.mode || "agent-browser"))}`); + for (const [index, step] of start.steps.entries()) lines.push(`${index + 1}. ${step}`); + lines.push(""); + lines.push(...renderLines("Checks", start.checks)); + lines.push(...renderLines("Success Signals", start.success_patterns)); + lines.push(...renderLines("Failure Signals", start.failure_patterns)); + lines.push("## Environment"); + for (const [key, value] of Object.entries(start.environment)) lines.push(`- ${key}=${value}`); + lines.push(""); + + return `${lines.join("\n").trimEnd()}\n`; +} + +export function commandTestStart(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findCase(ctx.root, args); + const start = buildStart(ctx.root, item, args); + const output = typeof options.output === "string" ? options.output : undefined; + const content = options.json === true ? `${JSON.stringify(start, null, 2)}\n` : renderMarkdownStart(start); + + writeOrPrint(content, output); + return 0; +} + +export function commandTestResult(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findCase(ctx.root, args); + const { result, errors } = buildTestResult(ctx.root, item, options); + if (!result) { + for (const error of errors) console.error(`ERROR: ${error}`); + return 1; + } + + const resultPath = join(result.evidence_dir, "result.json"); + mkdirSync(dirname(resultPath), { recursive: true }); + writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + + if (options.json === true) console.log(JSON.stringify(result, null, 2)); + else console.log(renderTestResult(result).trimEnd()); + return 0; +} + +function buildAutomationRun( + root: string, + item: StructuredItem, + args: string[], + options: Record, +): TestAutomationRun { + const now = new Date(); + const startedAtLocal = localIsoWithOffset(now); + const id = scalar(item.fields, "id"); + const sourceEnv = runtimeEnv(root); + const runId = typeof options["run-id"] === "string" ? options["run-id"] : `${timestampSlug(startedAtLocal)}-${id}`; + const script = automationScript(item); + const scriptPath = script ? resolve(root, script) : ""; + const evidenceDir = typeof options.output === "string" + ? options.output + : join("reports", "evidence", runId); + const locator = caseLocator(args); + const consoleLog = join(evidenceDir, "console.log"); + const reportPath = join("reports", `${runId}.md`); + const reportCommand = `bin/lbs test report ${locator} --since "${startedAtLocal}" --console-log ${consoleLog} --evidence-dir ${evidenceDir} --output ${reportPath}`; + const runCommand = [ + "bin/lbs", + "test", + "run", + locator, + "--run-id", + runId, + "--output", + evidenceDir, + ].join(" "); + + return { + run_id: runId, + started_at: now.toISOString(), + started_at_local: startedAtLocal, + case: caseSummary(item), + setup_automation: setupAutomation(root, item, runId, evidenceDir), + automation: { + script, + script_path: scriptPath, + exists: scriptPath ? existsSync(scriptPath) : false, + required_env: [...listValue(item.fields, "automation_env"), ...listValue(item.fields, "automation_env_any")], + evidence_dir: evidenceDir, + console_log: consoleLog, + network_log: join(evidenceDir, "network.log"), + screenshot: join(evidenceDir, "screenshot.png"), + automation_result_json: join(evidenceDir, "automation-result.json"), + result_json: join(evidenceDir, "result.json"), + command: runCommand, + report_command: reportCommand, + env_defaults: automationEnvDefaults(item, sourceEnv), + env_aliases: caseAutomationReadiness(item, sourceEnv).env_aliases, + pipeline_env_required: caseAutomationReadiness(item, sourceEnv).pipeline_env_required, + }, + }; +} + +function renderAutomationRun(run: TestAutomationRun): string { + const lines: string[] = []; + lines.push(`# Test Automation: ${run.case.id}`); + lines.push(""); + lines.push(`Run: ${run.run_id}`); + lines.push(`Started: ${run.started_at_local}`); + lines.push(`Script: ${run.automation.script || "None declared."}`); + lines.push(`Script path: ${run.automation.script_path || "None declared."}`); + lines.push(`Script exists: ${run.automation.exists ? "yes" : "no"}`); + lines.push(""); + lines.push("## Setup Automation"); + if (run.setup_automation.length === 0) lines.push("- None declared."); + for (const setup of run.setup_automation) { + lines.push(`- ${setup.entry}`); + lines.push(` command: ${setup.command}`); + if (setup.dry_run_command) lines.push(` dry_run_command: ${setup.dry_run_command}`); + lines.push(` evidence_dir: ${setup.evidence_dir}`); + lines.push(` exists: ${setup.exists ? "yes" : "no"}`); + } + lines.push(""); + lines.push("## Commands"); + lines.push(`- run: ${run.automation.command}`); + lines.push(`- report: ${run.automation.report_command}`); + lines.push(""); + lines.push("## Evidence Files"); + lines.push(`- console_log: ${run.automation.console_log}`); + lines.push(`- network_log: ${run.automation.network_log}`); + lines.push(`- screenshot: ${run.automation.screenshot}`); + lines.push(`- automation_result_json: ${run.automation.automation_result_json}`); + lines.push(`- result_json: ${run.automation.result_json}`); + lines.push(""); + lines.push(...renderLines("Required Env", run.automation.required_env)); + lines.push("## Automation Env Defaults"); + const defaults = Object.entries(run.automation.env_defaults); + if (defaults.length === 0) lines.push("- None declared."); + for (const [key, value] of defaults) lines.push(`- ${key}=${redactEnvValue(key, value)}`); + lines.push("## Automation Env Aliases"); + if (run.automation.env_aliases.length === 0) lines.push("- None declared."); + for (const alias of run.automation.env_aliases) { + lines.push(`- ${alias.target} <- ${alias.source} (${alias.configured ? "configured" : "missing"})`); + } + if (run.automation.pipeline_env_required) lines.push("- Pipeline env is case-specific; global LANGBOT_PIPELINE_URL fallback is disabled."); + return `${lines.join("\n").trimEnd()}\n`; +} + +function automationEnv( + root: string, + item: StructuredItem, + run: TestAutomationRun, + evidenceDir: string, + options: Record, +): Record { + const baseEnv = runtimeEnv(root); + const envDefaults = automationEnvDefaults(item, baseEnv); + return { + ...processEnv, + ...envDefaults, + ...baseEnv, + ...resolvedAutomationEnvOverrides(item, baseEnv), + ...Object.fromEntries( + Object.keys(envDefaults) + .filter((key) => baseEnv[key] !== undefined) + .map((key) => [key, baseEnv[key]]), + ), + LBS_ROOT: root, + LBS_CASE_ID: String(run.case.id), + LBS_RUN_ID: run.run_id, + LBS_STARTED_AT: run.started_at, + LBS_STARTED_AT_LOCAL: run.started_at_local, + LBS_EVIDENCE_DIR: resolve(evidenceDir), + LBS_HEADED: options.headed === true ? "1" : processEnv.LBS_HEADED, + }; +} + +function readSetupResult(setup: SetupAutomation): { status?: string; reason?: string } { + try { + return JSON.parse(readFileSync(join(setup.evidence_dir, "automation-result.json"), "utf8")); + } catch { + return {}; + } +} + +function writeSetupFailureResult(run: TestAutomationRun, setup: SetupAutomation, exitStatus: number | null): void { + const now = new Date(); + const setupResult = readSetupResult(setup); + const status = setupResult.status && setupResult.status !== "pass" + ? setupResult.status + : exitStatus === 2 ? "env_issue" : "fail"; + const result = { + source: "setup_automation", + case_id: run.case.id, + run_id: run.run_id, + status, + reason: setupResult.reason || `Setup automation failed: ${setup.entry}`, + failed_setup: setup, + exit_status: exitStatus, + started_at: run.started_at, + started_at_local: run.started_at_local, + finished_at: now.toISOString(), + finished_at_local: localIsoWithOffset(now), + evidence_collected: ["api_diagnostic"], + }; + writeFileSync(join(run.automation.evidence_dir, "automation-result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); + writeFileSync(join(run.automation.evidence_dir, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); +} + +function executionTail(value: string | Buffer | null | undefined): string { + return String(value ?? "").trim().slice(-4000); +} + +function exitStatusFromResultStatus(status: string): number { + if (status === "pass") return 0; + if (status === "blocked" || status === "env_issue" || status === "flaky") return 2; + return 1; +} + +function executionStatusFromExitStatus(status: number): string { + if (status === 0) return "ok"; + if (status === 2) return "classified"; + return "nonzero"; +} + +function executionFromAutomationResultFile( + evidenceDir: string, + caseId: string, + runId: string, +): { status: string; exit_status: number; reason: string; result_status: string; path: string } | null { + const resultPath = join(evidenceDir, "automation-result.json"); + if (!existsSync(resultPath)) return null; + try { + const parsed = JSON.parse(readFileSync(resultPath, "utf8")) as Record; + if (parsed.case_id !== caseId || parsed.run_id !== runId || typeof parsed.status !== "string") return null; + const exitStatus = exitStatusFromResultStatus(parsed.status); + return { + status: executionStatusFromExitStatus(exitStatus), + exit_status: exitStatus, + reason: typeof parsed.reason === "string" ? parsed.reason : "automation-result.json completed", + result_status: parsed.status, + path: resultPath, + }; + } catch { + return null; + } +} + +function runSetupAutomation( + ctx: CommandContext, + item: StructuredItem, + run: TestAutomationRun, + setup: SetupAutomation, + options: Record, +): { status: number; execution: Record } { + if (!setup.exists) { + if (options.json !== true) console.error(`ERROR: setup automation target not found: ${setup.entry}`); + writeSetupFailureResult(run, setup, 1); + return { + status: 1, + execution: { entry: setup.entry, status: "nonzero", exit_status: 1, reason: "setup automation target not found" }, + }; + } + mkdirSync(setup.evidence_dir, { recursive: true }); + if (options.json !== true) { + console.log(`Setup: ${setup.entry}`); + console.log(`Setup evidence: ${setup.evidence_dir}`); + } + const env = automationEnv(ctx.root, item, run, setup.evidence_dir, options); + const args = setup.kind === "case" + ? [ + lbsScriptPath(), + "--root", + ctx.root, + "test", + "run", + setup.target, + "--run-id", + `${run.run_id}-${setup.target}`, + "--output", + setup.evidence_dir, + ...(options.headed === true ? ["--headed"] : []), + ] + : [setupAutomationScriptPath(ctx.root, parseSetupAutomationEntry(setup.entry)), ...setup.args]; + const result = spawnSync(execPath, args, { + cwd: ctx.root, + env, + encoding: "utf8", + stdio: options.json === true ? "pipe" : "inherit", + }); + if (result.error) { + if (options.json !== true) console.error(`ERROR: failed to run setup automation: ${result.error.message}`); + writeSetupFailureResult(run, setup, 1); + return { + status: 1, + execution: { + entry: setup.entry, + status: "nonzero", + exit_status: 1, + reason: result.error.message, + stdout: executionTail(result.stdout), + stderr: executionTail(result.stderr), + }, + }; + } + const status = result.status ?? 1; + if (status !== 0) writeSetupFailureResult(run, setup, status); + return { + status, + execution: { + entry: setup.entry, + status: status === 0 ? "ok" : "nonzero", + exit_status: status, + stdout: executionTail(result.stdout), + stderr: executionTail(result.stderr), + }, + }; +} + +export function commandTestRun(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findCase(ctx.root, args); + const run = buildAutomationRun(ctx.root, item, args, options); + const output = typeof options.plan_output === "string" ? options.plan_output : undefined; + + if (options["dry-run"] === true) { + const content = options.json === true ? `${JSON.stringify(run, null, 2)}\n` : renderAutomationRun(run); + writeOrPrint(content, output); + return 0; + } + + if (!run.automation.script) { + console.error(`ERROR: case has no automation script: ${run.case.id}`); + return 1; + } + if (!run.automation.exists) { + console.error(`ERROR: automation script not found: ${run.automation.script_path}`); + return 1; + } + + mkdirSync(run.automation.evidence_dir, { recursive: true }); + if (options.json !== true) { + console.log(`Run: ${run.run_id}`); + console.log(`Evidence: ${run.automation.evidence_dir}`); + console.log(`Report command: ${run.automation.report_command}`); + } + + const setupExecutions: Array> = []; + for (const setup of run.setup_automation) { + const { status, execution } = runSetupAutomation(ctx, item, run, setup, options); + setupExecutions.push(execution); + if (status !== 0) { + if (options.json === true) { + console.log(JSON.stringify({ + run, + setup_executions: setupExecutions, + automation_execution: null, + exit_status: status, + }, null, 2)); + } + return status; + } + } + + const env = automationEnv(ctx.root, item, run, run.automation.evidence_dir, options); + const result = spawnSync(execPath, [run.automation.script_path], { + cwd: ctx.root, + env, + encoding: "utf8", + stdio: options.json === true ? "pipe" : "inherit", + }); + + if (result.error) { + const fileExecution = executionFromAutomationResultFile( + run.automation.evidence_dir, + String(run.case.id), + run.run_id, + ); + if (fileExecution) { + if (options.json !== true) { + console.error(`WARN: automation spawn reported an error, but ${fileExecution.path} completed: ${result.error.message}`); + } + if (options.json === true) { + console.log(JSON.stringify({ + run, + setup_executions: setupExecutions, + automation_execution: { + ...fileExecution, + spawn_error: result.error.message, + stdout: executionTail(result.stdout), + stderr: executionTail(result.stderr), + }, + exit_status: fileExecution.exit_status, + }, null, 2)); + } + return fileExecution.exit_status; + } + if (options.json !== true) console.error(`ERROR: failed to run automation: ${result.error.message}`); + if (options.json === true) { + console.log(JSON.stringify({ + run, + setup_executions: setupExecutions, + automation_execution: { + status: "nonzero", + exit_status: 1, + reason: result.error.message, + stdout: executionTail(result.stdout), + stderr: executionTail(result.stderr), + }, + exit_status: 1, + }, null, 2)); + } + return 1; + } + const status = result.status ?? 1; + if (options.json === true) { + console.log(JSON.stringify({ + run, + setup_executions: setupExecutions, + automation_execution: { + status: executionStatusFromExitStatus(status), + exit_status: status, + stdout: executionTail(result.stdout), + stderr: executionTail(result.stderr), + }, + exit_status: status, + }, null, 2)); + } + return status; +} + + +function buildReport(root: string, item: StructuredItem, options: Record): TestReport { + const env = loadEnv(root); + const mode = caseMode(item); + const related = relatedTroubleshooting(root, item).map((entry) => ({ + id: scalar(entry.fields, "id"), + title: scalar(entry.fields, "title"), + patterns: listValue(entry.fields, "patterns"), + verification: scalar(entry.fields, "verification"), + })); + + return { + generated_at: new Date().toISOString(), + case: caseSummary(item), + result_options: ["pass", "fail", "blocked", "env_issue", "flaky"], + automation_result: readAutomationResultEvidence(options), + manual_evidence: manualEvidenceTemplate(mode), + environment: envSummary(item, env), + required_skills: listValue(item.fields, "skills"), + steps: listValue(item.fields, "steps"), + checks: listValue(item.fields, "checks"), + diagnostics: listValue(item.fields, "diagnostics"), + evidence_required: listValue(item.fields, "evidence_required"), + success_patterns: listValue(item.fields, "success_patterns"), + failure_patterns: listValue(item.fields, "failure_patterns"), + expected_failures: listValue(item.fields, "expected_failures"), + troubleshooting: related, + log_guard: scanStructuredLogSources(root, item, options), + }; +} + +function renderLines(title: string, values: string[]): string[] { + const lines = [`## ${title}`]; + if (values.length === 0) lines.push("- None declared."); + else for (const value of values) lines.push(`- ${value}`); + lines.push(""); + return lines; +} + +function renderFinding(finding: LogFinding): string { + return renderLogFinding(finding); +} + +function renderSuccessSignal(signal: LogSuccessSignal): string { + return renderLogSuccessSignal(signal); +} + +function renderMarkdownReport(report: TestReport): string { + const reportCase = report.case; + const evidence = report.manual_evidence; + const environment = report.environment; + const logGuard = report.log_guard; + const troubleshooting = report.troubleshooting; + const automation = report.automation_result; + const lines: string[] = []; + + lines.push(`# Test Report: ${reportCase.id}`); + lines.push(""); + lines.push(`Generated: ${report.generated_at}`); + lines.push(`Title: ${reportCase.title}`); + lines.push(`Skill: ${reportCase.skill}`); + lines.push(`Mode: ${reportCase.mode}`); + lines.push(`Area: ${reportCase.area}`); + lines.push(`Type: ${reportCase.type}`); + lines.push(""); + lines.push("## Result"); + if (automation.status === "loaded" && automation.result) { + lines.push(`- result: ${automation.result}`); + if (automation.reason) lines.push(`- reason: ${automation.reason}`); + if (automation.url) lines.push(`- target_tested: ${automation.url}`); + if (automation.path) lines.push(`- automation_result: ${automation.path}`); + if (automation.artifacts) lines.push(`- artifacts: ${JSON.stringify(automation.artifacts)}`); + } else { + lines.push(`- result: ${evidence.result}`); + for (const [key, value] of Object.entries(evidence)) { + if (key !== "result") lines.push(`- ${key}: ${value}`); + } + } + lines.push(""); + lines.push("## Automation Result"); + lines.push(`- status: ${automation.status}`); + if (automation.path) lines.push(`- path: ${automation.path}`); + if (automation.result) lines.push(`- result: ${automation.result}`); + if (automation.reason) lines.push(`- reason: ${automation.reason}`); + if (automation.duration_ms !== undefined) lines.push(`- duration_ms: ${automation.duration_ms}`); + if (automation.started_at_local) lines.push(`- started_at_local: ${automation.started_at_local}`); + if (automation.finished_at_local) lines.push(`- finished_at_local: ${automation.finished_at_local}`); + if (automation.url) lines.push(`- url: ${automation.url}`); + if (automation.expected_text) lines.push(`- expected_text: ${automation.expected_text}`); + if (automation.metrics_summary) { + lines.push("- metrics_summary:"); + lines.push(` ${JSON.stringify(automation.metrics_summary)}`); + } + if (automation.thresholds_summary) { + lines.push("- thresholds_summary:"); + lines.push(` ${JSON.stringify(automation.thresholds_summary)}`); + } + if (automation.artifacts) { + lines.push("- artifacts:"); + lines.push(` ${JSON.stringify(automation.artifacts)}`); + } + lines.push(""); + lines.push("## Environment"); + for (const [key, value] of Object.entries(environment)) lines.push(`- ${key}=${value}`); + lines.push(""); + lines.push(`## ${stepHeading(String(reportCase.mode || "agent-browser"))}`); + for (const [index, step] of report.steps.entries()) lines.push(`${index + 1}. ${step}`); + lines.push(""); + lines.push(...renderLines("Checks", report.checks)); + lines.push(...renderLines("Diagnostics", report.diagnostics)); + lines.push(...renderLines("Required Evidence", report.evidence_required)); + lines.push(...renderLines("Success Signals", report.success_patterns)); + lines.push(...renderLines("Failure Signals", report.failure_patterns)); + lines.push(...renderLines("Expected Failures", report.expected_failures)); + lines.push("## Log Guard"); + lines.push(`- status: ${logGuard.status}`); + lines.push(`- scan_mode: ${logGuard.scan.mode}`); + if (logGuard.scan.since) lines.push(`- since: ${logGuard.scan.since}`); + if (logGuard.scan.until) lines.push(`- until: ${logGuard.scan.until}`); + if (logGuard.scan.tail_lines !== undefined) lines.push(`- tail_lines: ${logGuard.scan.tail_lines}`); + if (logGuard.scan.warnings.length > 0) { + lines.push("- scan_warnings:"); + for (const warning of logGuard.scan.warnings) lines.push(` - ${warning}`); + } + if (logGuard.sources.length === 0) { + lines.push("- sources: no log files provided; run with --backend-log, --frontend-log, or --console-log to scan logs."); + } else { + lines.push("- sources:"); + for (const source of logGuard.sources) { + const origin = source.auto_detected ? ", auto" : ""; + const total = source.total_line_count === undefined ? "" : `/${source.total_line_count}`; + const range = source.start_line === undefined || source.end_line === undefined + ? "" + : `, lines ${source.start_line}-${source.end_line}`; + const timestamped = source.timestamped_line_count === undefined ? "" : `, ${source.timestamped_line_count} timestamped`; + lines.push(` - ${source.source}: ${source.path} (${source.status}${origin}, ${source.line_count}${total} lines${range}${timestamped})`); + } + } + lines.push("- findings:"); + if (logGuard.findings.length === 0) lines.push(" - None."); + else for (const finding of logGuard.findings) lines.push(` ${renderFinding(finding)}`); + lines.push("- success_signals:"); + if (logGuard.success_signals.length === 0) lines.push(" - None."); + else for (const signal of logGuard.success_signals) lines.push(` ${renderSuccessSignal(signal)}`); + lines.push(""); + lines.push("## Related Troubleshooting"); + if (troubleshooting.length === 0) lines.push("- None declared."); + for (const entry of troubleshooting) { + lines.push(`- ${entry.id}: ${entry.title}`); + if (entry.patterns.length > 0) lines.push(` patterns: ${entry.patterns.join(" | ")}`); + if (entry.verification) lines.push(` verification: ${entry.verification}`); + } + lines.push(""); + lines.push("## Decision Notes"); + if (isProbeMode(String(reportCase.mode))) { + lines.push("- Probe results should be judged from the declared checks and required evidence for the same run."); + } else { + lines.push("- API/curl diagnostics can explain the run, but cannot make this UI case pass by themselves."); + } + lines.push("- Do not paste API keys, OAuth secrets, tokens, or localStorage token values into this report."); + lines.push(""); + + return `${lines.join("\n").trimEnd()}\n`; +} + +function writeOrPrint(content: string, output: string | undefined): void { + if (!output) { + console.log(content.trimEnd()); + return; + } + const path = resolve(output); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, "utf8"); + console.log(path); +} + +export function commandTestReport(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findCase(ctx.root, args); + const report = buildReport(ctx.root, item, options); + const output = typeof options.output === "string" ? options.output : undefined; + const content = options.json === true ? `${JSON.stringify(report, null, 2)}\n` : renderMarkdownReport(report); + + writeOrPrint(content, output); + return 0; +} diff --git a/skills/src/commands/trouble.ts b/skills/src/commands/trouble.ts new file mode 100644 index 000000000..6429ab4f7 --- /dev/null +++ b/skills/src/commands/trouble.ts @@ -0,0 +1,95 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { CommandContext } from "../types.ts"; +import { fail, optionString, parseOptions, usage } from "../cli.ts"; +import { findStructuredItem, getSkill, loadStructuredItems, scalar, slugify, todayIso, yamlList, yamlQuote } from "../fs.ts"; + +function troubleshootingYamlPath(root: string, skillName: string, id: string): string { + const skill = getSkill(root, skillName); + const dir = join(skill.path, "troubleshooting"); + mkdirSync(dir, { recursive: true }); + return join(dir, `${id}.yaml`); +} + +function legacyTroubleshootingPath(root: string, skillName: string): string { + const skill = getSkill(root, skillName); + const refsDir = join(skill.path, "references"); + mkdirSync(refsDir, { recursive: true }); + const path = join(refsDir, "troubleshooting.md"); + if (!existsSync(path)) writeFileSync(path, "# Troubleshooting\n\n", "utf8"); + return path; +} + +export function commandTroubleList(ctx: CommandContext): number { + const skill = ctx.args[2]; + const yamlItems = loadStructuredItems(ctx.root, "troubleshooting", skill); + for (const item of yamlItems) { + console.log(`${item.skill}\t${scalar(item.fields, "id")}\t${scalar(item.fields, "title")}`); + } + + if (skill && yamlItems.length === 0) { + const legacyPath = legacyTroubleshootingPath(ctx.root, skill); + const text = readFileSync(legacyPath, "utf8"); + const headings = Array.from(text.matchAll(/^##\s+(.+)$/gm)).map((match) => match[1]); + for (const heading of headings) console.log(`${skill}\tlegacy\t${heading}`); + } + return 0; +} + +export function commandTroubleShow(ctx: CommandContext): number { + const positional = ctx.args.slice(2); + if (positional.length < 1 || positional.length > 2) usage(); + const item = positional.length === 1 + ? findStructuredItem(ctx.root, "troubleshooting", positional[0]) + : findStructuredItem(ctx.root, "troubleshooting", positional[0], positional[1]); + console.log(item.raw.trimEnd()); + return 0; +} + +export function commandTroubleSearch(ctx: CommandContext): number { + const query = ctx.args[2]?.toLowerCase(); + if (!query) usage(); + const items = loadStructuredItems(ctx.root, "troubleshooting").filter((item) => item.raw.toLowerCase().includes(query)); + for (const item of items) { + console.log(`${item.skill}\t${scalar(item.fields, "id")}\t${scalar(item.fields, "title")}`); + } + return 0; +} + +export function commandTroubleAdd(ctx: CommandContext): number { + const skill = ctx.args[2]; + if (!skill) usage(); + const { options } = parseOptions(ctx.args.slice(3)); + for (const key of ["title", "symptom", "cause", "fix"]) { + if (!optionString(options, key)) fail(`--${key} is required`); + } + + const title = optionString(options, "title") ?? ""; + const symptom = optionString(options, "symptom") ?? ""; + const id = optionString(options, "id") ?? slugify(title); + const path = troubleshootingYamlPath(ctx.root, skill, id); + if (existsSync(path)) fail(`troubleshooting entry already exists: ${path}`); + + const text = + `id: ${id}\n` + + `title: ${yamlQuote(title)}\n` + + `date: ${todayIso()}\n` + + "symptoms:\n" + + yamlList([symptom]) + + "\npatterns:\n" + + yamlList([symptom]) + + "\nlikely_causes:\n" + + yamlList([optionString(options, "cause") ?? ""]) + + "\nfix_steps:\n" + + yamlList([optionString(options, "fix") ?? ""]) + + "\nverification: " + + yamlQuote(optionString(options, "verify") ?? "Add the command, UI signal, or log line that proves the fix worked.") + + "\nrelated_cases:\n" + + yamlList([]) + + "\n"; + + writeFileSync(path, text, "utf8"); + appendFileSync(legacyTroubleshootingPath(ctx.root, skill), `\n## ${id}: ${title}\n\nSee \`../troubleshooting/${id}.yaml\`.\n`, "utf8"); + console.log(path); + return 0; +} diff --git a/skills/src/commands/validate.ts b/skills/src/commands/validate.ts new file mode 100644 index 000000000..590032ef8 --- /dev/null +++ b/skills/src/commands/validate.ts @@ -0,0 +1,449 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { stderr } from "node:process"; +import type { Skill, StructuredItem } from "../types.ts"; +import { loadFixtureItems } from "../fixtures.ts"; +import { + caseEvidenceValues, + caseModeValues, + casePriorityValues, + caseRequiredLists, + caseRequiredStrings, + caseRiskValues, + caseTypeValues, + requiredEnvKeys, + suiteRequiredLists, + suiteRequiredStrings, + suiteTypeValues, + troubleRequiredLists, + troubleRequiredStrings, + troubleshootingCategoryValues, +} from "../constants.ts"; +import { boolValue, envExamplePath, envPath, listValue, loadSkills, loadStructuredItems, parseEnvFile, scalar } from "../fs.ts"; +import { envKeyPattern, isEnvAnyGroup, splitEnvAnyGroup } from "../env-groups.ts"; +import { parseSetupAutomationEntry, validateSetupAutomationEntry } from "../setup-automation.ts"; + +const refRe = /(?:\]\(|`)(references\/[A-Za-z0-9_.\-/]+\.md)(?:\)|`)/g; + +function validateStructuredItem(item: StructuredItem, requiredStrings: string[], requiredLists: string[]): string[] { + const errors: string[] = []; + const listKeys = item.path.includes("/cases/") && scalar(item.fields, "mode") === "probe" + ? requiredLists.filter((key) => key !== "env") + : requiredLists; + for (const key of requiredStrings) { + if (!scalar(item.fields, key)) errors.push(`${item.path}: missing '${key}'`); + } + for (const key of listKeys) { + if (listValue(item.fields, key).length === 0) errors.push(`${item.path}: missing '${key}' entries`); + } + const id = scalar(item.fields, "id"); + if (id && !/^[a-z0-9][a-z0-9_-]*$/.test(id)) { + errors.push(`${item.path}: id must use lowercase letters, digits, dashes, or underscores`); + } + return errors; +} + +function validateEnum(item: StructuredItem, key: string, values: string[]): string[] { + const value = scalar(item.fields, key); + if (!value) return []; + return values.includes(value) ? [] : [`${item.path}: '${key}' must be one of ${values.join(", ")}`]; +} + +function validateListEnum(item: StructuredItem, key: string, values: string[]): string[] { + const allowed = new Set(values); + return listValue(item.fields, key) + .filter((value) => !allowed.has(value)) + .map((value) => `${item.path}: '${key}' contains unsupported value '${value}'`); +} + +function validateDuplicateListValues(item: StructuredItem, keys: string[]): string[] { + const errors: string[] = []; + for (const key of keys) { + const seen = new Set(); + for (const value of listValue(item.fields, key)) { + if (seen.has(value)) errors.push(`${item.path}: '${key}' contains duplicate value '${value}'`); + seen.add(value); + } + } + return errors; +} + +function validateEnvKeyList(item: StructuredItem, key: string): string[] { + return listValue(item.fields, key) + .filter((value) => !envKeyPattern.test(value)) + .map((value) => `${item.path}: '${key}' contains invalid env key '${value}'`); +} + +function validateEnvKeyScalar(item: StructuredItem, key: string): string[] { + const value = scalar(item.fields, key); + if (!value) return []; + return envKeyPattern.test(value) + ? [] + : [`${item.path}: '${key}' contains invalid env key '${value}'`]; +} + +function validateJsonScalar(item: StructuredItem, key: string): string[] { + const value = scalar(item.fields, key); + if (!value) return []; + try { + JSON.parse(value); + return []; + } catch (error) { + return [`${item.path}: '${key}' must be valid JSON: ${(error as Error).message}`]; + } +} + +function validateEnvAnyList(item: StructuredItem, key: string): string[] { + return listValue(item.fields, key) + .filter((value) => !isEnvAnyGroup(value)) + .map((value) => `${item.path}: '${key}' contains invalid env any-group '${value}'`); +} + +function validateCaseItem(root: string, item: StructuredItem, skillNames: Set, troubleIds: Set, caseIds: Set): string[] { + const errors = [ + ...validateEnum(item, "mode", caseModeValues), + ...validateEnum(item, "type", caseTypeValues), + ...validateEnum(item, "priority", casePriorityValues), + ...validateEnum(item, "risk", caseRiskValues), + ...validateListEnum(item, "evidence_required", caseEvidenceValues), + ...validateDuplicateListValues(item, [ + "tags", + "skills", + "env", + "env_any", + "automation_env", + "automation_env_any", + "setup_automation", + "setup_provides_env", + "evidence_required", + "troubleshooting", + ]), + ...validateEnvKeyList(item, "env"), + ...validateEnvAnyList(item, "env_any"), + ...validateEnvKeyList(item, "automation_env"), + ...validateEnvAnyList(item, "automation_env_any"), + ...validateEnvKeyList(item, "setup_provides_env"), + ...validateEnvKeyScalar(item, "automation_pipeline_url_env"), + ...validateEnvKeyScalar(item, "automation_pipeline_name_env"), + ...validateJsonScalar(item, "automation_filesystem_checks_json"), + ...validateJsonScalar(item, "metrics_thresholds_json"), + ...validateJsonScalar(item, "load_profile_json"), + ...validateJsonScalar(item, "fault_model_json"), + ...listValue(item.fields, "setup_automation").flatMap((entry) => ( + validateSetupAutomationEntry(root, entry, caseIds).map((error) => `${item.path}: ${error}`) + )), + ]; + + if (boolValue(item.fields, "ci_eligible") === undefined) { + errors.push(`${item.path}: missing or invalid boolean 'ci_eligible'`); + } + + for (const skill of listValue(item.fields, "skills")) { + if (!skillNames.has(skill)) errors.push(`${item.path}: references unknown skill '${skill}'`); + } + + for (const id of listValue(item.fields, "troubleshooting")) { + if (!troubleIds.has(id)) errors.push(`${item.path}: references unknown troubleshooting '${id}'`); + } + + const automation = scalar(item.fields, "automation"); + if (!automation && listValue(item.fields, "automation_env").length > 0) { + errors.push(`${item.path}: 'automation_env' requires 'automation'`); + } + if (!automation && listValue(item.fields, "automation_env_any").length > 0) { + errors.push(`${item.path}: 'automation_env_any' requires 'automation'`); + } + if (!automation && (scalar(item.fields, "automation_pipeline_url_env") || scalar(item.fields, "automation_pipeline_name_env"))) { + errors.push(`${item.path}: automation pipeline env aliases require 'automation'`); + } + if (listValue(item.fields, "setup_provides_env").length > 0 && listValue(item.fields, "setup_automation").length === 0) { + errors.push(`${item.path}: 'setup_provides_env' requires 'setup_automation'`); + } + for (const key of ["automation_pipeline_url_env", "automation_pipeline_name_env"]) { + const value = scalar(item.fields, key); + if (!value) continue; + const declared = new Set([ + ...listValue(item.fields, "env"), + ...listValue(item.fields, "env_any").flatMap(splitEnvAnyGroup), + ...listValue(item.fields, "automation_env"), + ...listValue(item.fields, "automation_env_any").flatMap(splitEnvAnyGroup), + ]); + if (!declared.has(value)) { + errors.push(`${item.path}: '${key}' value '${value}' must be listed in env, env_any, automation_env, or automation_env_any`); + } + } + if (automation && !existsSync(join(root, automation))) { + errors.push(`${item.path}: automation script does not exist: ${automation}`); + } + for (const entry of listValue(item.fields, "setup_automation")) { + const spec = parseSetupAutomationEntry(entry); + if (spec.kind === "case" && spec.target === scalar(item.fields, "id")) { + errors.push(`${item.path}: setup_automation cannot reference the same case '${spec.target}'`); + } + } + + const timeout = scalar(item.fields, "automation_response_timeout_ms"); + if (timeout && (!/^\d+$/.test(timeout) || Number.parseInt(timeout, 10) <= 0)) { + errors.push(`${item.path}: 'automation_response_timeout_ms' must be a positive integer string`); + } + for (const key of [ + "automation_debug_chat_load_requests", + "automation_debug_chat_load_concurrency", + "automation_debug_chat_load_timeout_ms", + "automation_debug_chat_load_response_p95_ms", + "automation_debug_chat_load_first_response_p95_ms", + ]) { + const value = scalar(item.fields, key); + if (value && (!/^\d+$/.test(value) || Number.parseInt(value, 10) <= 0)) { + errors.push(`${item.path}: '${key}' must be a positive integer string`); + } + } + for (const key of [ + "automation_debug_chat_load_min_error_count", + "automation_debug_chat_load_min_ok_count", + "automation_debug_chat_load_min_provider_fault_count", + "automation_fake_provider_first_token_delay_ms", + "automation_fake_provider_chunk_delay_ms", + "automation_fake_provider_chunk_count", + "automation_fake_provider_fail_first_n", + "automation_fake_provider_fail_every_n", + ]) { + const value = scalar(item.fields, key); + if (value && (!/^\d+$/.test(value) || Number.parseInt(value, 10) < 0)) { + errors.push(`${item.path}: '${key}' must be a non-negative integer string`); + } + } + for (const key of ["automation_debug_chat_load_max_error_rate", "automation_debug_chat_load_min_error_rate"]) { + const value = scalar(item.fields, key); + if (value && (!/^(?:0(?:\.\d+)?|1(?:\.0+)?)$/.test(value))) { + errors.push(`${item.path}: '${key}' must be a number string between 0 and 1`); + } + } + const fakeProviderFaultStatus = scalar(item.fields, "automation_fake_provider_fault_status"); + if (fakeProviderFaultStatus) { + const parsed = Number.parseInt(fakeProviderFaultStatus, 10); + if (!/^\d+$/.test(fakeProviderFaultStatus) || parsed < 400 || parsed > 599) { + errors.push(`${item.path}: 'automation_fake_provider_fault_status' must be an HTTP 4xx or 5xx status string`); + } + } + const streamOutput = scalar(item.fields, "automation_stream_output"); + if (streamOutput && !["0", "1", "false", "true"].includes(streamOutput)) { + errors.push(`${item.path}: 'automation_stream_output' must be one of 0, 1, false, or true`); + } + for (const key of [ + "automation_debug_chat_load_stream", + "automation_debug_chat_load_reset", + "automation_debug_chat_load_fail_on_final_mismatch", + "automation_fake_provider_fail_after_first_chunk", + "automation_fake_provider_dynamic_response", + ]) { + const value = scalar(item.fields, key); + if (value && !["0", "1", "false", "true"].includes(value)) { + errors.push(`${item.path}: '${key}' must be one of 0, 1, false, or true`); + } + } + const imageBase64Fixture = scalar(item.fields, "automation_image_base64_fixture"); + if (imageBase64Fixture && !existsSync(join(root, imageBase64Fixture))) { + errors.push(`${item.path}: automation image fixture does not exist: ${imageBase64Fixture}`); + } + + return errors; +} + +function validateSetupAutomationCycles(caseItems: StructuredItem[]): string[] { + const byId = new Map(caseItems.map((item) => [scalar(item.fields, "id"), item])); + const visiting = new Set(); + const visited = new Set(); + const errors: string[] = []; + + function visit(id: string, path: string[]): void { + if (visited.has(id)) return; + if (visiting.has(id)) { + const cycle = [...path.slice(path.indexOf(id)), id].join(" -> "); + const item = byId.get(id); + errors.push(`${item?.path ?? id}: setup_automation case cycle detected: ${cycle}`); + return; + } + const item = byId.get(id); + if (!item) return; + visiting.add(id); + for (const entry of listValue(item.fields, "setup_automation")) { + const spec = parseSetupAutomationEntry(entry); + if (spec.kind === "case") visit(spec.target, [...path, spec.target]); + } + visiting.delete(id); + visited.add(id); + } + + for (const id of byId.keys()) visit(id, [id]); + return errors; +} + +function validateTroubleshootingItem(item: StructuredItem, caseIds: Set): string[] { + const errors = [ + ...validateEnum(item, "category", troubleshootingCategoryValues), + ...validateDuplicateListValues(item, ["symptoms", "patterns", "likely_causes", "fix_steps", "related_cases"]), + ]; + for (const id of listValue(item.fields, "related_cases")) { + if (!caseIds.has(id)) errors.push(`${item.path}: references unknown case '${id}'`); + } + return errors; +} + +function validateFixtures(root: string, caseIds: Set): string[] { + const { items, errors } = loadFixtureItems(root); + const result = [...errors]; + const seen = new Map(); + for (const item of items) { + if (!/^[a-z0-9][a-z0-9_-]*$/.test(item.id)) { + result.push(`${item.manifest_path}: fixture id '${item.id}' must use lowercase letters, digits, dashes, or underscores`); + } + if (seen.has(item.id)) { + result.push(`${item.manifest_path}: duplicate fixture id '${item.id}' also used by ${seen.get(item.id)}`); + } else { + seen.set(item.id, item.manifest_path); + } + if (!item.exists) result.push(`${item.manifest_path}: fixture path does not exist: ${item.path}`); + for (const caseId of item.related_cases) { + if (!caseIds.has(caseId)) result.push(`${item.manifest_path}: fixture '${item.id}' references unknown case '${caseId}'`); + } + } + return result; +} + +function validateSuiteItem(item: StructuredItem, caseIds: Set): string[] { + const errors = [ + ...validateEnum(item, "type", suiteTypeValues), + ...validateEnum(item, "priority", casePriorityValues), + ...validateDuplicateListValues(item, ["tags", "cases"]), + ]; + for (const id of listValue(item.fields, "cases")) { + if (!caseIds.has(id)) errors.push(`${item.path}: references unknown case '${id}'`); + } + return errors; +} + +function validateDuplicateIds(items: StructuredItem[], label: string): string[] { + const errors: string[] = []; + const seen = new Map(); + for (const item of items) { + const id = scalar(item.fields, "id"); + if (!id) continue; + const key = `${item.skill}:${id}`; + if (seen.has(key)) errors.push(`${item.path}: duplicate ${label} id '${id}' also used by ${seen.get(key)}`); + else seen.set(key, item.path); + } + return errors; +} + +function validateGlobalDuplicateIds(items: StructuredItem[], label: string): string[] { + const errors: string[] = []; + const seen = new Map(); + for (const item of items) { + const id = scalar(item.fields, "id"); + if (!id) continue; + if (seen.has(id)) errors.push(`${item.path}: duplicate global ${label} id '${id}' also used by ${seen.get(id)}`); + else seen.set(id, item.path); + } + return errors; +} + +function validateEnv(root: string): string[] { + const path = envPath(root); + const examplePath = envExamplePath(root); + const errors: string[] = []; + if (!existsSync(path)) return [`${path}: missing shared env file`]; + const env = parseEnvFile(path); + for (const key of requiredEnvKeys) { + if (!(key in env)) errors.push(`${path}: missing ${key}`); + } + if (!existsSync(examplePath)) { + errors.push(`${examplePath}: missing env template`); + } else { + const example = parseEnvFile(examplePath); + for (const key of requiredEnvKeys) { + if (!(key in example)) errors.push(`${examplePath}: missing template key ${key}`); + } + } + return errors; +} + +function validateSchemas(root: string): string[] { + const errors: string[] = []; + for (const name of ["case.schema.json", "suite.schema.json", "troubleshooting.schema.json", "skill-index.schema.json"]) { + const path = join(root, "schemas", name); + if (!existsSync(path)) { + errors.push(`${path}: missing schema`); + continue; + } + try { + JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + errors.push(`${path}: invalid JSON schema (${String(error)})`); + } + } + return errors; +} + +function validateSkill(skill: Skill): string[] { + const errors: string[] = []; + if (!skill.name) errors.push(`${skill.path}: missing frontmatter name`); + if (!skill.description) errors.push(`${skill.path}: missing frontmatter description`); + if (skill.name && skill.name !== skill.directory) { + errors.push(`${skill.path}: name '${skill.name}' does not match directory '${skill.directory}'`); + } + + const refs = new Set(); + for (const match of skill.body.matchAll(refRe)) refs.add(match[1]); + for (const ref of Array.from(refs).sort()) { + if (!existsSync(join(skill.path, ref))) { + errors.push(`${skill.path}: referenced file does not exist: ${ref}`); + } + } + + const legacyTroubleshooting = join(skill.path, "references", "troubleshooting.md"); + if (existsSync(legacyTroubleshooting)) { + const text = readFileSync(legacyTroubleshooting, "utf8"); + if (text.includes("\n## ") && !text.includes("### Symptom")) { + errors.push(`${legacyTroubleshooting}: troubleshooting entries should include '### Symptom'`); + } + } + + return errors; +} + +export function commandValidate(root: string): number { + const skills = loadSkills(root); + const caseItems = loadStructuredItems(root, "cases"); + const suiteItems = loadStructuredItems(root, "suites"); + const troubleItems = loadStructuredItems(root, "troubleshooting"); + const skillNames = new Set(skills.map((skill) => skill.name)); + const caseIds = new Set(caseItems.map((item) => scalar(item.fields, "id")).filter(Boolean)); + const troubleIds = new Set(troubleItems.map((item) => scalar(item.fields, "id")).filter(Boolean)); + const errors = [ + ...validateEnv(root), + ...validateSchemas(root), + ...skills.flatMap(validateSkill), + ...caseItems.flatMap((item) => validateStructuredItem(item, caseRequiredStrings, caseRequiredLists)), + ...caseItems.flatMap((item) => validateCaseItem(root, item, skillNames, troubleIds, caseIds)), + ...validateSetupAutomationCycles(caseItems), + ...suiteItems.flatMap((item) => validateStructuredItem(item, suiteRequiredStrings, suiteRequiredLists)), + ...suiteItems.flatMap((item) => validateSuiteItem(item, caseIds)), + ...troubleItems.flatMap((item) => validateStructuredItem(item, troubleRequiredStrings, troubleRequiredLists)), + ...troubleItems.flatMap((item) => validateTroubleshootingItem(item, caseIds)), + ...validateFixtures(root, caseIds), + ...validateDuplicateIds(caseItems, "case"), + ...validateDuplicateIds(suiteItems, "suite"), + ...validateDuplicateIds(troubleItems, "troubleshooting"), + ...validateGlobalDuplicateIds(caseItems, "case"), + ...validateGlobalDuplicateIds(suiteItems, "suite"), + ...validateGlobalDuplicateIds(troubleItems, "troubleshooting"), + ]; + + if (errors.length > 0) { + for (const error of errors) stderr.write(`ERROR: ${error}\n`); + return 1; + } + console.log("OK"); + return 0; +} diff --git a/skills/src/constants.ts b/skills/src/constants.ts new file mode 100644 index 000000000..5cfe37f8a --- /dev/null +++ b/skills/src/constants.ts @@ -0,0 +1,59 @@ +export const requiredEnvKeys = [ + "LANGBOT_FRONTEND_URL", + "LANGBOT_BACKEND_URL", + "LANGBOT_DEV_FRONTEND_URL", + "LANGBOT_REPO", + "LANGBOT_WEB_REPO", + "LANGBOT_BROWSER_PROFILE", + "LANGBOT_CHROMIUM_EXECUTABLE", +]; + +export const caseModeValues = ["agent-browser", "probe"]; +export const caseTypeValues = [ + "smoke", + "regression", + "feature", + "provider", + "exploratory", + "contract", + "performance", + "reliability", + "chaos", + "security", +]; +export const casePriorityValues = ["p0", "p1", "p2"]; +export const caseRiskValues = ["low", "medium", "high"]; +export const caseEvidenceValues = [ + "ui", + "screenshot", + "console", + "network", + "backend_log", + "frontend_log", + "api_diagnostic", + "filesystem", + "metrics", + "trace", + "profile", + "resource_log", +]; +export const testResultStatusValues = ["pass", "fail", "blocked", "env_issue", "flaky"]; +export const troubleshootingCategoryValues = ["product", "env_issue", "external_dependency", "blocked", "flaky"]; +export const suiteTypeValues = [ + "smoke", + "regression", + "release_gate", + "exploratory", + "contract", + "performance", + "reliability", + "chaos", + "security", +]; +export const suiteRequiredStrings = ["id", "title", "description", "type", "priority"]; +export const suiteRequiredLists = ["tags", "cases"]; + +export const caseRequiredStrings = ["id", "title", "mode", "area", "type", "priority", "risk"]; +export const caseRequiredLists = ["tags", "skills", "env", "steps", "checks", "evidence_required"]; +export const troubleRequiredStrings = ["id", "title", "verification"]; +export const troubleRequiredLists = ["symptoms", "patterns", "likely_causes", "fix_steps"]; diff --git a/skills/src/env-groups.ts b/skills/src/env-groups.ts new file mode 100644 index 000000000..069193600 --- /dev/null +++ b/skills/src/env-groups.ts @@ -0,0 +1,10 @@ +export const envKeyPattern = /^[A-Z][A-Z0-9_]*$/; + +export function splitEnvAnyGroup(value: string): string[] { + return value.split("|").map((item) => item.trim()).filter(Boolean); +} + +export function isEnvAnyGroup(value: string): boolean { + const keys = splitEnvAnyGroup(value); + return keys.length >= 2 && new Set(keys).size === keys.length && keys.every((key) => envKeyPattern.test(key)); +} diff --git a/skills/src/fixtures.ts b/skills/src/fixtures.ts new file mode 100644 index 000000000..698ad3d8b --- /dev/null +++ b/skills/src/fixtures.ts @@ -0,0 +1,86 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadSkills } from "./fs.ts"; + +export type FixtureItem = { + skill: string; + manifest_path: string; + id: string; + title: string; + path: string; + kind: string; + related_cases: string[]; + checks: string[]; + absolute_path: string; + exists: boolean; +}; + +export type FixtureLoadResult = { + items: FixtureItem[]; + errors: string[]; +}; + +function stringField(data: Record, key: string): string { + const value = data[key]; + return typeof value === "string" ? value : ""; +} + +function stringList(data: Record, key: string): string[] { + const value = data[key]; + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +export function loadFixtureItems(root: string, skillFilter?: string): FixtureLoadResult { + const items: FixtureItem[] = []; + const errors: string[] = []; + const skills = loadSkills(root).filter((skill) => !skillFilter || skill.directory === skillFilter || skill.name === skillFilter); + + for (const skill of skills) { + const manifestPath = join(skill.path, "fixtures", "fixtures.json"); + if (!existsSync(manifestPath)) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch (error) { + errors.push(`${manifestPath}: invalid fixture manifest JSON (${String(error)})`); + continue; + } + + if (!Array.isArray(parsed)) { + errors.push(`${manifestPath}: fixture manifest must be a JSON array`); + continue; + } + + for (const [index, entry] of parsed.entries()) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + errors.push(`${manifestPath}: fixture entry ${index} must be an object`); + continue; + } + const data = entry as Record; + const id = stringField(data, "id"); + const title = stringField(data, "title"); + const path = stringField(data, "path"); + const kind = stringField(data, "kind") || "file"; + if (!id || !title || !path) { + errors.push(`${manifestPath}: fixture entry ${index} must include id, title, and path`); + continue; + } + const absolutePath = join(skill.path, path); + items.push({ + skill: skill.directory, + manifest_path: manifestPath, + id, + title, + path, + kind, + related_cases: stringList(data, "related_cases"), + checks: stringList(data, "checks"), + absolute_path: absolutePath, + exists: existsSync(absolutePath), + }); + } + } + + return { items, errors }; +} diff --git a/skills/src/fs.ts b/skills/src/fs.ts new file mode 100644 index 000000000..238e253ff --- /dev/null +++ b/skills/src/fs.ts @@ -0,0 +1,226 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import type { ParsedYaml, Skill, StructuredItem, StructuredItemKind } from "./types.ts"; +import { fail } from "./cli.ts"; + +const frontmatterRe = /^---\n([\s\S]*?)\n---\n/; + +export function statIsDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +export function skillsRoot(root: string): string { + const nested = join(root, "skills"); + return existsSync(nested) && statIsDirectory(nested) ? nested : root; +} + +export function envPath(root: string): string { + return join(skillsRoot(root), ".env"); +} + +export function envLocalPath(root: string): string { + return join(skillsRoot(root), ".env.local"); +} + +export function envExamplePath(root: string): string { + return join(skillsRoot(root), ".env.example"); +} + +export function loadEnv(root: string): Record { + return { + ...parseEnvFile(envPath(root)), + ...parseEnvFile(envLocalPath(root)), + }; +} + +export function listDirectories(root: string): string[] { + return readdirSync(root) + .filter((name) => !name.startsWith(".")) + .filter((name) => statIsDirectory(join(root, name))) + .sort(); +} + +export function parseFrontmatter(text: string): { meta: Record; body: string } { + const match = text.match(frontmatterRe); + if (!match) return { meta: {}, body: text }; + + const meta: Record = {}; + for (const line of match[1].split("\n")) { + const sep = line.indexOf(":"); + if (sep === -1) continue; + const key = line.slice(0, sep).trim(); + const value = line.slice(sep + 1).trim().replace(/^["']|["']$/g, ""); + meta[key] = value; + } + + return { meta, body: text.slice(match[0].length) }; +} + +export function loadSkills(root: string): Skill[] { + const skills: Skill[] = []; + const base = skillsRoot(root); + for (const directory of listDirectories(base)) { + const skillPath = join(base, directory); + const skillMd = join(skillPath, "SKILL.md"); + if (!existsSync(skillMd)) continue; + const text = readFileSync(skillMd, "utf8"); + const { meta, body } = parseFrontmatter(text); + skills.push({ + path: skillPath, + directory, + name: meta.name ?? "", + description: meta.description ?? "", + body, + }); + } + return skills; +} + +export function getSkill(root: string, skillName: string): Skill { + const skill = loadSkills(root).find((item) => item.directory === skillName || item.name === skillName); + if (!skill) fail(`unknown skill: ${skillName}`); + return skill; +} + +export function parseEnvFile(path: string): Record { + if (!existsSync(path)) return {}; + const env: Record = {}; + for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const sep = line.indexOf("="); + if (sep === -1) continue; + const key = line.slice(0, sep).trim(); + const value = line.slice(sep + 1).trim().replace(/^["']|["']$/g, ""); + env[key] = value; + } + return env; +} + +export function globMarkdownRefs(skillPath: string): string[] { + const refsDir = join(skillPath, "references"); + if (!existsSync(refsDir)) return []; + return readdirSync(refsDir) + .filter((name) => name.endsWith(".md")) + .sort() + .map((name) => join("references", name)); +} + +export function globYamlFiles(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((name) => name.endsWith(".yaml") || name.endsWith(".yml")) + .sort() + .map((name) => join(dir, name)); +} + +function unquote(value: string): string { + return value.trim().replace(/^["']|["']$/g, ""); +} + +function parseScalarValue(value: string): string | boolean { + const trimmed = value.trim(); + if (/^["'].*["']$/.test(trimmed)) return unquote(trimmed); + if (trimmed === "true") return true; + if (trimmed === "false") return false; + return trimmed; +} + +export function parseYamlLite(text: string): ParsedYaml { + const fields: ParsedYaml = {}; + let currentList: string | null = null; + + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.replace(/\s+$/, ""); + if (!line.trim() || line.trim().startsWith("#")) continue; + + const pair = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/); + if (pair) { + const key = pair[1]; + const value = pair[2]; + if (value === "") { + fields[key] = []; + currentList = key; + } else { + fields[key] = parseScalarValue(value); + currentList = null; + } + continue; + } + + const item = line.match(/^\s*-\s*(.*)$/); + if (item && currentList) { + const existing = fields[currentList]; + if (Array.isArray(existing)) existing.push(unquote(item[1])); + } + } + + return fields; +} + +export function scalar(fields: ParsedYaml, key: string): string { + const value = fields[key]; + return typeof value === "string" ? value : ""; +} + +export function boolValue(fields: ParsedYaml, key: string): boolean | undefined { + const value = fields[key]; + return typeof value === "boolean" ? value : undefined; +} + +export function listValue(fields: ParsedYaml, key: string): string[] { + const value = fields[key]; + return Array.isArray(value) ? value : []; +} + +export function yamlQuote(value: string): string { + return JSON.stringify(value); +} + +export function yamlList(values: string[]): string { + return values.map((value) => ` - ${yamlQuote(value)}`).join("\n"); +} + +export function loadStructuredItems(root: string, kind: StructuredItemKind, skillFilter?: string): StructuredItem[] { + const skills = skillFilter ? [getSkill(root, skillFilter)] : loadSkills(root); + const items: StructuredItem[] = []; + for (const skill of skills) { + for (const path of globYamlFiles(join(skill.path, kind))) { + const raw = readFileSync(path, "utf8"); + items.push({ path, skill: skill.directory, fields: parseYamlLite(raw), raw }); + } + } + return items.sort((a, b) => `${a.skill}:${scalar(a.fields, "id")}`.localeCompare(`${b.skill}:${scalar(b.fields, "id")}`)); +} + +export function findStructuredItem( + root: string, + kind: StructuredItemKind, + skillOrId: string, + maybeId?: string, +): StructuredItem { + const skillFilter = maybeId ? skillOrId : undefined; + const id = maybeId ?? skillOrId; + const matches = loadStructuredItems(root, kind, skillFilter).filter((item) => scalar(item.fields, "id") === id); + if (matches.length === 0) fail(`unknown ${kind.slice(0, -1)}: ${id}`); + if (matches.length > 1) { + fail(`ambiguous ${kind.slice(0, -1)} '${id}', specify skill: ${matches.map((item) => item.skill).join(", ")}`); + } + return matches[0]; +} + +export function slugify(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} diff --git a/skills/src/lbs.ts b/skills/src/lbs.ts new file mode 100644 index 000000000..8329a5803 --- /dev/null +++ b/skills/src/lbs.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +import { argv, exit } from "node:process"; +import { parseGlobalArgs, usage } from "./cli.ts"; +import { commandCaseList, commandCaseNew, commandCaseShow } from "./commands/case.ts"; +import { commandEnvDoctor, commandEnvShow } from "./commands/env.ts"; +import { commandFixtureCheck, commandFixtureList } from "./commands/fixture.ts"; +import { commandIndex, commandList, commandNewRef, commandNewSkill } from "./commands/skill.ts"; +import { commandLogGuard, commandLogScan, commandLogWatch } from "./commands/log.ts"; +import { commandSuiteList, commandSuiteNew, commandSuitePlan, commandSuiteReport, commandSuiteRun, commandSuiteShow, commandSuiteStart } from "./commands/suite.ts"; +import { commandTestPlan, commandTestRecommend, commandTestReport, commandTestResult, commandTestRun, commandTestStart } from "./commands/test.ts"; +import { commandTroubleAdd, commandTroubleList, commandTroubleSearch, commandTroubleShow } from "./commands/trouble.ts"; +import { commandValidate } from "./commands/validate.ts"; + +async function main(): Promise { + const ctx = parseGlobalArgs(argv.slice(2)); + const command = ctx.args[0]; + if (!command) usage(); + + if (command === "list") return commandList(ctx); + if (command === "validate") return commandValidate(ctx.root); + if (command === "index") return commandIndex(ctx); + if (command === "new-skill") return commandNewSkill(ctx); + if (command === "new-ref") return commandNewRef(ctx); + + if (command === "env") { + const sub = ctx.args[1]; + if (sub === "show") return commandEnvShow(ctx); + if (sub === "doctor") return await commandEnvDoctor(ctx); + } + + if (command === "fixture") { + const sub = ctx.args[1]; + if (sub === "list") return commandFixtureList(ctx); + if (sub === "check") return commandFixtureCheck(ctx); + } + + if (command === "log") { + const sub = ctx.args[1]; + if (sub === "scan") return commandLogScan(ctx); + if (sub === "watch") return await commandLogWatch(ctx); + if (sub === "guard") return commandLogGuard(ctx); + } + + if (command === "case") { + const sub = ctx.args[1]; + if (sub === "new") return commandCaseNew(ctx); + if (sub === "list") return commandCaseList(ctx); + if (sub === "show") return commandCaseShow(ctx); + } + + if (command === "suite") { + const sub = ctx.args[1]; + if (sub === "new") return commandSuiteNew(ctx); + if (sub === "list") return commandSuiteList(ctx); + if (sub === "show") return commandSuiteShow(ctx); + if (sub === "plan") return commandSuitePlan(ctx); + if (sub === "start") return commandSuiteStart(ctx); + if (sub === "run") return commandSuiteRun(ctx); + if (sub === "report") return commandSuiteReport(ctx); + } + + if (command === "test") { + const sub = ctx.args[1]; + if (sub === "plan") return commandTestPlan(ctx); + if (sub === "recommend") return commandTestRecommend(ctx); + if (sub === "start") return commandTestStart(ctx); + if (sub === "run") return commandTestRun(ctx); + if (sub === "report") return commandTestReport(ctx); + if (sub === "result") return commandTestResult(ctx); + } + + if (command === "trouble") { + const sub = ctx.args[1]; + if (sub === "list") return commandTroubleList(ctx); + if (sub === "show") return commandTroubleShow(ctx); + if (sub === "search") return commandTroubleSearch(ctx); + if (sub === "add") return commandTroubleAdd(ctx); + } + + usage(); +} + +exit(await main()); diff --git a/skills/src/log-guard.ts b/skills/src/log-guard.ts new file mode 100644 index 000000000..6f7f541a7 --- /dev/null +++ b/skills/src/log-guard.ts @@ -0,0 +1,825 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import type { StructuredItem } from "./types.ts"; +import { listValue, loadEnv, loadStructuredItems, scalar } from "./fs.ts"; + +export type LogSourceName = "backend" | "frontend" | "console"; +export type FindingSeverity = + | "fail" + | "warning" + | "matched_troubleshooting" + | "env_issue" + | "ignored_expected_issue" + | "missing_input"; + +export type LogFinding = { + source: LogSourceName; + path: string; + severity: FindingSeverity; + kind: string; + pattern: string; + line?: number; + excerpt?: string; + troubleshooting_id?: string; + troubleshooting_title?: string; + related_to_case?: boolean; +}; + +export type LogLine = { + number: number; + text: string; +}; + +export type LogSuccessSignal = { + source: LogSourceName; + path: string; + kind: "case_success_pattern"; + pattern: string; + line?: number; + excerpt?: string; +}; + +export type LogScanMode = + | "whole-file" + | "since" + | "until" + | "since+until" + | "tail-lines" + | "since+tail-lines" + | "until+tail-lines" + | "since+until+tail-lines"; + +export type LogScanConfig = { + mode: LogScanMode; + since?: string; + since_epoch_ms?: number; + until?: string; + until_epoch_ms?: number; + tail_lines?: number; + warnings: string[]; +}; + +export type LogSourceSummary = { + source: LogSourceName; + path: string; + status: "scanned" | "missing" | "auto_not_found"; + line_count: number; + total_line_count?: number; + start_line?: number; + end_line?: number; + timestamped_line_count?: number; + auto_detected?: boolean; +}; + +export type LogGuardPatternContext = { + successPatterns?: string[]; + failurePatterns?: string[]; + expectedFailures?: string[]; + relatedTroubleshootingIds?: string[]; +}; + +export type LogGuardResult = { + status: string; + scan: LogScanConfig; + sources: LogSourceSummary[]; + success_signals: LogSuccessSignal[]; + findings: LogFinding[]; +}; + +export type AutomationResultEvidence = { + status: "not_provided" | "missing" | "invalid" | "loaded"; + path?: string; + result?: string; + reason?: string; + duration_ms?: number; + started_at?: string; + started_at_local?: string; + finished_at?: string; + finished_at_local?: string; + url?: string; + prompt?: string; + expected_text?: string; + metrics_summary?: Record; + thresholds_summary?: Record; + artifacts?: Record; +}; + +type MutableScanState = { + findings: LogFinding[]; + successSignals: LogSuccessSignal[]; + seenFindings: Set; + seenSuccessSignals: Set; +}; + +const secretAssignmentRe = /\b(api[_-]?key|authorization|credential|jwt|oauth|password|secret|token)\s*[:=]\s*["']?([^"',\s]+)/gi; +const bearerSecretRe = /\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/i; +const openAiStyleSecretRe = /\bsk-[A-Za-z0-9_-]{6,}\b/i; + +const unexpectedPatterns: Array<{ + kind: string; + pattern: string; + regex: RegExp; + severity: FindingSeverity; + sources?: LogSourceName[]; +}> = [ + { kind: "python_traceback", pattern: "Traceback", regex: /\bTraceback(?: \(most recent call last\))?/i, severity: "fail" }, + { + kind: "unretrieved_task_exception", + pattern: "Task exception was never retrieved", + regex: /Task exception was never retrieved/i, + severity: "fail", + }, + { + kind: "unawaited_coroutine", + pattern: "RuntimeWarning: coroutine .* was never awaited", + regex: /RuntimeWarning:\s+coroutine .* was never awaited/i, + severity: "fail", + }, + { + kind: "unclosed_client_session", + pattern: "Unclosed client session", + regex: /Unclosed client session/i, + severity: "fail", + }, + { kind: "unclosed_connector", pattern: "Unclosed connector", regex: /Unclosed connector/i, severity: "fail" }, + { kind: "key_error", pattern: "KeyError", regex: /(^|[^A-Za-z])KeyError(?:\b|:)/, severity: "fail" }, + { kind: "type_error", pattern: "TypeError", regex: /(^|[^A-Za-z])TypeError(?:\b|:)/, severity: "fail" }, + { + kind: "attribute_error", + pattern: "AttributeError", + regex: /(^|[^A-Za-z])AttributeError(?:\b|:)/, + severity: "fail", + }, + { + kind: "frontend_uncaught_error", + pattern: "Uncaught frontend error", + regex: /\bUncaught (?:[A-Za-z]*Error|Exception)|Unhandled(?: promise rejection|Rejection)/i, + severity: "fail", + sources: ["console", "frontend"], + }, + { + kind: "http_5xx", + pattern: "HTTP 5xx resource failure", + regex: /Failed to load resource: the server responded with a status of 5\d\d|HTTP\/\d(?:\.\d)?\s+5\d\d/i, + severity: "fail", + }, + { kind: "error_log", pattern: "ERROR or CRITICAL log line", regex: /\b(?:ERROR|CRITICAL)\b/, severity: "warning" }, +]; + +export function logPatternContextFromStructuredItem(item: StructuredItem): LogGuardPatternContext { + return { + successPatterns: listValue(item.fields, "success_patterns"), + failurePatterns: listValue(item.fields, "failure_patterns"), + expectedFailures: listValue(item.fields, "expected_failures"), + relatedTroubleshootingIds: listValue(item.fields, "troubleshooting"), + }; +} + +export function scanStructuredLogSources( + root: string, + item: StructuredItem, + options: Record, +): LogGuardResult { + return scanLogSources(root, options, logPatternContextFromStructuredItem(item)); +} + +export function scanLogSources( + root: string, + options: Record, + context: LogGuardPatternContext = {}, +): LogGuardResult { + const env = loadEnv(root); + const scan = parseScanConfig(optionsWithEvidenceWindow(options)); + const configuredSources: Array<{ source: LogSourceName; option: string }> = [ + { source: "backend", option: "backend-log" }, + { source: "frontend", option: "frontend-log" }, + { source: "console", option: "console-log" }, + ]; + const sources: LogSourceSummary[] = []; + const state: MutableScanState = { + findings: [], + successSignals: [], + seenFindings: new Set(), + seenSuccessSignals: new Set(), + }; + + for (const warning of scan.warnings) { + addFinding(state, { + source: "backend", + path: "log-scan-options", + severity: "missing_input", + kind: "invalid_log_scan_option", + pattern: warning, + }); + } + + for (const configured of configuredSources) { + const explicitPath = options[configured.option]; + const autoPath = configured.source === "backend" && options["no-auto-log"] !== true + ? latestLangBotLogPath(env) + : null; + const rawPath = typeof explicitPath === "string" ? explicitPath : autoPath; + const autoDetected = typeof explicitPath !== "string" && rawPath === autoPath; + if (!rawPath) { + if (configured.source === "backend" && options["no-auto-log"] !== true) { + const logsDir = env.LANGBOT_REPO ? join(env.LANGBOT_REPO, "data", "logs") : "LANGBOT_REPO/data/logs"; + sources.push({ source: "backend", path: join(logsDir, "langbot-*.log"), status: "auto_not_found", line_count: 0, auto_detected: true }); + } + continue; + } + + const path = resolve(rawPath); + if (!existsSync(path)) { + sources.push({ source: configured.source, path, status: "missing", line_count: 0 }); + if (!autoDetected) { + addFinding(state, { + source: configured.source, + path, + severity: "missing_input", + kind: "missing_log_file", + pattern: `${configured.option} path does not exist`, + }); + } + continue; + } + + const text = readFileSync(path, "utf8"); + scanLogTextIntoState(root, configured.source, path, text, scan, context, sources, state); + } + + finalizeMissingSuccessSignal(context, sources, state); + return buildLogGuardResult(scan, sources, state); +} + +export function scanLogText( + root: string, + source: LogSourceName, + path: string, + text: string, + options: Record = {}, + context: LogGuardPatternContext = {}, + baseLineNumber = 0, + includeMissingSuccessSignal = true, +): LogGuardResult { + const scan = parseScanConfig(options); + const sources: LogSourceSummary[] = []; + const state: MutableScanState = { + findings: [], + successSignals: [], + seenFindings: new Set(), + seenSuccessSignals: new Set(), + }; + + scanLogTextIntoState(root, source, resolve(path), text, scan, context, sources, state, baseLineNumber); + if (includeMissingSuccessSignal) finalizeMissingSuccessSignal(context, sources, state); + return buildLogGuardResult(scan, sources, state); +} + +function scanLogTextIntoState( + root: string, + source: LogSourceName, + path: string, + text: string, + scan: LogScanConfig, + context: LogGuardPatternContext, + sources: LogSourceSummary[], + state: MutableScanState, + baseLineNumber = 0, +): void { + const allLines = text.split(/\r?\n/).map((line, index) => ({ number: baseLineNumber + index + 1, text: line })); + const selected = selectLinesForScan(allLines, scan); + sources.push({ + source, + path, + status: "scanned", + line_count: selected.lines.length, + total_line_count: allLines.length, + start_line: selected.lines[0]?.number, + end_line: selected.lines[selected.lines.length - 1]?.number, + timestamped_line_count: selected.timestampedLineCount, + }); + + scanUnexpectedPatterns(state, source, path, selected.lines, context.expectedFailures ?? []); + scanCaseDeclaredPatterns( + state, + source, + path, + selected.lines, + context.successPatterns ?? [], + context.failurePatterns ?? [], + context.expectedFailures ?? [], + ); + scanTroubleshootingPatterns( + state, + source, + path, + selected.lines, + loadStructuredItems(root, "troubleshooting"), + new Set(context.relatedTroubleshootingIds ?? []), + context.expectedFailures ?? [], + ); +} + +function buildLogGuardResult(scan: LogScanConfig, sources: LogSourceSummary[], state: MutableScanState): LogGuardResult { + const scannedCount = sources.filter((source) => source.status === "scanned").length; + const status = scannedCount === 0 && state.findings.length === 0 + ? "not_run" + : state.findings.some((finding) => finding.severity === "fail" || finding.severity === "missing_input") + ? "fail" + : state.findings.some((finding) => finding.severity === "matched_troubleshooting" && finding.related_to_case !== false) + ? "fail" + : state.findings.some((finding) => finding.severity === "env_issue") + ? "env_issue" + : state.findings.some((finding) => finding.severity === "warning") + ? "warning" + : "pass"; + + return { status, scan, sources, success_signals: state.successSignals, findings: state.findings }; +} + +function finalizeMissingSuccessSignal( + context: LogGuardPatternContext, + sources: LogSourceSummary[], + state: MutableScanState, +): void { + const scannedCount = sources.filter((source) => source.status === "scanned").length; + const successPatterns = context.successPatterns ?? []; + if (scannedCount > 0 && successPatterns.length > 0 && state.successSignals.length === 0) { + addFinding(state, { + source: "backend", + path: "case-success-patterns", + severity: "warning", + kind: "missing_success_signal", + pattern: successPatterns.join(" | "), + excerpt: "No declared success_patterns matched the scanned log window.", + }); + } +} + +function shouldTreatAssignmentValueAsSecret(value: string): boolean { + const normalized = value.trim().replace(/^["']|["']$/g, ""); + const lower = normalized.toLowerCase(); + if (!normalized) return false; + if (["error", "invalid", "missing", "none", "null", "undefined", "redacted", "[redacted]"].includes(lower)) { + return false; + } + if (/^(error|invalid|missing|none|null|undefined)\b/i.test(normalized)) return false; + if (/^(your-|<|\$\{|example-|placeholder)/i.test(normalized)) return false; + if (openAiStyleSecretRe.test(normalized)) return true; + return normalized.length >= 8 && /[A-Za-z0-9]/.test(normalized); +} + +function redactSecretAssignments(text: string): string { + return text.replace(secretAssignmentRe, (match, key: string, value: string) => { + if (!shouldTreatAssignmentValueAsSecret(value)) return match; + return match.replace(value, "[redacted]"); + }); +} + +export function redactSecrets(text: string): string { + return redactSecretAssignments(text + .replace(/(\bauthorization\s*[:=]\s*bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1[redacted]") + .replace(/\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]") + .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]")); +} + +function hasSecretLeak(line: string): boolean { + secretAssignmentRe.lastIndex = 0; + const hasSecretAssignment = Array.from(line.matchAll(secretAssignmentRe)) + .some((match) => shouldTreatAssignmentValueAsSecret(match[2] ?? "")); + return hasSecretAssignment || bearerSecretRe.test(line) || openAiStyleSecretRe.test(line); +} + +function findingKey(finding: LogFinding): string { + return [ + finding.source, + finding.path, + finding.kind, + finding.pattern, + finding.line ?? "", + finding.troubleshooting_id ?? "", + ].join("\0"); +} + +function addFinding(state: MutableScanState, finding: LogFinding): void { + const key = findingKey(finding); + if (state.seenFindings.has(key)) return; + state.seenFindings.add(key); + state.findings.push(finding); +} + +function successSignalKey(signal: LogSuccessSignal): string { + return [signal.source, signal.path, signal.pattern, signal.line ?? ""].join("\0"); +} + +function addSuccessSignal(state: MutableScanState, signal: LogSuccessSignal): void { + const key = successSignalKey(signal); + if (state.seenSuccessSignals.has(key)) return; + state.seenSuccessSignals.add(key); + state.successSignals.push(signal); +} + +function isExpectedFinding(finding: LogFinding, expectedFailures: string[]): boolean { + if (finding.kind === "secret_leak" || finding.severity === "missing_input") return false; + const haystack = [ + finding.kind, + finding.pattern, + finding.troubleshooting_id ?? "", + finding.troubleshooting_title ?? "", + finding.excerpt ?? "", + ].join("\n").toLowerCase(); + return expectedFailures.some((item) => item && haystack.includes(item.toLowerCase())); +} + +function withExpectedSeverity(finding: LogFinding, expectedFailures: string[]): LogFinding { + if (!isExpectedFinding(finding, expectedFailures)) return finding; + return { ...finding, severity: "ignored_expected_issue" }; +} + +function scanUnexpectedPatterns( + state: MutableScanState, + source: LogSourceName, + path: string, + lines: LogLine[], + expectedFailures: string[], +): void { + for (const line of lines) { + for (const pattern of unexpectedPatterns) { + if (pattern.sources && !pattern.sources.includes(source)) continue; + if (!pattern.regex.test(line.text)) continue; + addFinding(state, withExpectedSeverity({ + source, + path, + severity: pattern.severity, + kind: pattern.kind, + pattern: pattern.pattern, + line: line.number, + excerpt: redactSecrets(line.text.trim()), + }, expectedFailures)); + } + + if (hasSecretLeak(line.text)) { + addFinding(state, { + source, + path, + severity: "fail", + kind: "secret_leak", + pattern: "secret-like value in logs", + line: line.number, + excerpt: redactSecrets(line.text.trim()), + }); + } + } +} + +function scanTroubleshootingPatterns( + state: MutableScanState, + source: LogSourceName, + path: string, + lines: LogLine[], + troubles: StructuredItem[], + relatedIds: Set, + expectedFailures: string[], +): void { + for (const entry of troubles) { + const id = scalar(entry.fields, "id"); + const title = scalar(entry.fields, "title"); + const category = scalar(entry.fields, "category"); + for (const pattern of listValue(entry.fields, "patterns")) { + const needle = pattern.toLowerCase(); + if (!needle) continue; + let matchesForPattern = 0; + for (const line of lines) { + if (!line.text.toLowerCase().includes(needle)) continue; + if (id === "plugin-runtime-timeout" && isModelRouteUnavailableText(line.text)) continue; + addFinding(state, withExpectedSeverity({ + source, + path, + severity: category === "env_issue" ? "env_issue" : "matched_troubleshooting", + kind: "troubleshooting_pattern", + pattern, + line: line.number, + excerpt: redactSecrets(line.text.trim()), + troubleshooting_id: id, + troubleshooting_title: title, + related_to_case: relatedIds.has(id), + }, expectedFailures)); + matchesForPattern += 1; + if (matchesForPattern >= 3) break; + } + } + } +} + +function isModelRouteUnavailableText(text: string): boolean { + return /model_not_found|no available channel for model|invalid api key|当前分组上游负载已饱和/i.test(text); +} + +function scanCaseDeclaredPatterns( + state: MutableScanState, + source: LogSourceName, + path: string, + lines: LogLine[], + successPatterns: string[], + failurePatterns: string[], + expectedFailures: string[], +): void { + for (const pattern of successPatterns) { + const needle = pattern.toLowerCase(); + if (!needle) continue; + let matchesForPattern = 0; + for (const line of lines) { + if (!line.text.toLowerCase().includes(needle)) continue; + addSuccessSignal(state, { + source, + path, + kind: "case_success_pattern", + pattern, + line: line.number, + excerpt: redactSecrets(line.text.trim()), + }); + matchesForPattern += 1; + if (matchesForPattern >= 3) break; + } + } + + for (const pattern of failurePatterns) { + const needle = pattern.toLowerCase(); + if (!needle) continue; + let matchesForPattern = 0; + for (const line of lines) { + if (!line.text.toLowerCase().includes(needle)) continue; + addFinding(state, withExpectedSeverity({ + source, + path, + severity: "fail", + kind: "case_failure_pattern", + pattern, + line: line.number, + excerpt: redactSecrets(line.text.trim()), + }, expectedFailures)); + matchesForPattern += 1; + if (matchesForPattern >= 3) break; + } + } +} + +function optionsWithEvidenceWindow(options: Record): Record { + if (typeof options.since === "string" && typeof options.until === "string") { + return options; + } + + const evidenceDir = evidenceDirFromOptions(options); + if (!evidenceDir) return options; + + const resultPath = automationResultPath(evidenceDir); + if (!existsSync(resultPath)) return options; + + try { + const result = JSON.parse(readFileSync(resultPath, "utf8")) as Record; + const enriched = { ...options }; + const startedAt = stringField(result, "started_at_local") ?? stringField(result, "started_at"); + const finishedAt = stringField(result, "finished_at_local") ?? stringField(result, "finished_at"); + + if (typeof enriched.since !== "string" && startedAt) { + enriched.since = startedAt; + } + if (typeof enriched.until !== "string" && finishedAt) { + enriched.until = finishedAt; + } + return enriched; + } catch { + return options; + } +} + +function stringField(data: Record, key: string): string | undefined { + const value = data[key]; + return typeof value === "string" && value.trim() ? value : undefined; +} + +function numberField(data: Record, key: string): number | undefined { + const value = data[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function objectField(data: Record, key: string): Record | undefined { + const value = data[key]; + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function evidenceDirFromOptions(options: Record): string | undefined { + const explicit = typeof options["evidence-dir"] === "string" ? options["evidence-dir"] : undefined; + if (explicit) return resolve(explicit); + const consoleLog = typeof options["console-log"] === "string" ? options["console-log"] : undefined; + return consoleLog ? dirname(resolve(consoleLog)) : undefined; +} + +function automationResultPath(evidenceDir: string): string { + const primary = join(evidenceDir, "automation-result.json"); + if (existsSync(primary)) return primary; + return join(evidenceDir, "result.json"); +} + +export function readAutomationResultEvidence(options: Record): AutomationResultEvidence { + const evidenceDir = evidenceDirFromOptions(options); + if (!evidenceDir) return { status: "not_provided" }; + + const resultPath = automationResultPath(evidenceDir); + if (!existsSync(resultPath)) return { status: "missing", path: resultPath }; + + try { + const result = JSON.parse(readFileSync(resultPath, "utf8")) as Record; + if (result.source === "final") { + return { + status: "not_provided", + path: resultPath, + reason: "only final result.json is present; automation-result.json was not found", + }; + } + return { + status: "loaded", + path: resultPath, + result: stringField(result, "status"), + reason: stringField(result, "reason"), + duration_ms: numberField(result, "duration_ms"), + started_at: stringField(result, "started_at"), + started_at_local: stringField(result, "started_at_local"), + finished_at: stringField(result, "finished_at"), + finished_at_local: stringField(result, "finished_at_local"), + url: stringField(result, "url"), + prompt: redactSecrets(stringField(result, "prompt") ?? ""), + expected_text: stringField(result, "expected_text"), + metrics_summary: objectField(result, "metrics_summary"), + thresholds_summary: objectField(result, "thresholds_summary"), + artifacts: objectField(result, "artifacts"), + }; + } catch (error) { + return { status: "invalid", path: resultPath, reason: String(error) }; + } +} + +export function latestLangBotLogPath(env: Record): string | null { + const repo = env.LANGBOT_REPO; + if (!repo) return null; + const logsDir = join(repo, "data", "logs"); + if (!existsSync(logsDir)) return null; + + const candidates = readdirSync(logsDir) + .filter((name) => /^langbot-.*\.log$/.test(name)) + .map((name) => join(logsDir, name)) + .filter((path) => { + try { + return statSync(path).isFile(); + } catch { + return false; + } + }) + .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs); + + return candidates[0] ?? null; +} + +export function parseScanConfig(options: Record): LogScanConfig { + const warnings: string[] = []; + const sinceInput = typeof options.since === "string" ? options.since : undefined; + const sinceMs = sinceInput ? Date.parse(sinceInput) : undefined; + const untilInput = typeof options.until === "string" ? options.until : undefined; + const untilMs = untilInput ? Date.parse(untilInput) : undefined; + const tailInput = typeof options["tail-lines"] === "string" ? options["tail-lines"] : undefined; + let tailLines: number | undefined; + + if (sinceInput && Number.isNaN(sinceMs)) { + warnings.push(`--since is not a valid date/time: ${sinceInput}`); + } + if (untilInput && Number.isNaN(untilMs)) { + warnings.push(`--until is not a valid date/time: ${untilInput}`); + } + + if (tailInput) { + const parsed = Number.parseInt(tailInput, 10); + if (!/^\d+$/.test(tailInput) || parsed <= 0) { + warnings.push(`--tail-lines must be a positive integer: ${tailInput}`); + } else { + tailLines = parsed; + } + } + + const hasSince = sinceInput !== undefined && sinceMs !== undefined && !Number.isNaN(sinceMs); + const hasUntil = untilInput !== undefined && untilMs !== undefined && !Number.isNaN(untilMs); + const hasTail = tailLines !== undefined; + let mode: LogScanMode = "whole-file"; + if (hasSince && hasUntil && hasTail) { + mode = "since+until+tail-lines"; + } else if (hasSince && hasUntil) { + mode = "since+until"; + } else if (hasSince && hasTail) { + mode = "since+tail-lines"; + } else if (hasUntil && hasTail) { + mode = "until+tail-lines"; + } else if (hasSince) { + mode = "since"; + } else if (hasUntil) { + mode = "until"; + } else if (hasTail) { + mode = "tail-lines"; + } + + return { + mode, + since: hasSince ? sinceInput : undefined, + since_epoch_ms: hasSince ? sinceMs : undefined, + until: hasUntil ? untilInput : undefined, + until_epoch_ms: hasUntil ? untilMs : undefined, + tail_lines: tailLines, + warnings, + }; +} + +function selectLinesForScan(lines: LogLine[], scan: LogScanConfig): { lines: LogLine[]; timestampedLineCount: number } { + let selected = lines; + let timestampedLineCount = 0; + + if (scan.since_epoch_ms !== undefined || scan.until_epoch_ms !== undefined) { + const offsetMinutes = + timezoneOffsetMinutes(scan.since) + ?? timezoneOffsetMinutes(scan.until) + ?? -new Date(scan.since_epoch_ms ?? scan.until_epoch_ms ?? Date.now()).getTimezoneOffset(); + const yearHint = new Date(scan.since_epoch_ms ?? scan.until_epoch_ms ?? Date.now()).getUTCFullYear(); + let includeCurrentBlock = false; + const filtered = lines.filter((line) => { + const timestamp = parseLogLineTimestampMs(line.text, yearHint, offsetMinutes); + if (timestamp !== null) { + timestampedLineCount += 1; + includeCurrentBlock = + (scan.since_epoch_ms === undefined || timestamp >= scan.since_epoch_ms) + && (scan.until_epoch_ms === undefined || timestamp <= scan.until_epoch_ms); + return includeCurrentBlock; + } + return includeCurrentBlock; + }); + selected = timestampedLineCount === 0 ? lines : filtered; + } else { + timestampedLineCount = lines.reduce((count, line) => ( + parseLogLineTimestampMs(line.text, new Date().getFullYear(), -new Date().getTimezoneOffset()) === null + ? count + : count + 1 + ), 0); + } + + if (scan.tail_lines !== undefined && selected.length > scan.tail_lines) { + selected = selected.slice(-scan.tail_lines); + } + + return { lines: selected, timestampedLineCount }; +} + +function timezoneOffsetMinutes(input: string | undefined): number | null { + if (!input) return null; + if (/[zZ]$/.test(input)) return 0; + const match = input.match(/([+-])(\d{2}):?(\d{2})$/); + if (!match) return null; + const sign = match[1] === "-" ? -1 : 1; + return sign * (Number.parseInt(match[2], 10) * 60 + Number.parseInt(match[3], 10)); +} + +function parseLogLineTimestampMs(line: string, yearHint: number, offsetMinutes: number): number | null { + const fullIso = line.match(/^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2})?)\]?/); + if (fullIso) { + const timestamp = Date.parse(fullIso[1].replace(" ", "T")); + return Number.isNaN(timestamp) ? null : timestamp; + } + + const langBot = line.match(/^\[(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})\]/); + if (!langBot) return null; + const [, month, day, hour, minute, second, millisecond] = langBot; + return Date.UTC( + yearHint, + Number.parseInt(month, 10) - 1, + Number.parseInt(day, 10), + Number.parseInt(hour, 10), + Number.parseInt(minute, 10), + Number.parseInt(second, 10), + Number.parseInt(millisecond, 10), + ) - offsetMinutes * 60 * 1000; +} + +export function renderLogFinding(finding: LogFinding): string { + const location = finding.line ? `${finding.source}:${finding.line}` : finding.source; + const trouble = finding.troubleshooting_id ? ` (${finding.troubleshooting_id})` : ""; + const related = finding.related_to_case === true ? ", related" : ""; + const excerpt = finding.excerpt ? ` - ${finding.excerpt}` : ""; + return `- [${finding.severity}] ${location}: ${finding.kind}${trouble}${related}; pattern: ${finding.pattern}${excerpt}`; +} + +export function renderLogSuccessSignal(signal: LogSuccessSignal): string { + const location = signal.line ? `${signal.source}:${signal.line}` : signal.source; + const excerpt = signal.excerpt ? ` - ${signal.excerpt}` : ""; + return `- ${location}: ${signal.pattern}${excerpt}`; +} + +export function strictLogGuardExitCode(result: LogGuardResult): number { + return result.status === "fail" || result.status === "env_issue" ? 1 : 0; +} diff --git a/skills/src/readiness.ts b/skills/src/readiness.ts new file mode 100644 index 000000000..945fcb598 --- /dev/null +++ b/skills/src/readiness.ts @@ -0,0 +1,277 @@ +import { env as processEnv } from "node:process"; +import type { StructuredItem } from "./types.ts"; +import { loadFixtureItems } from "./fixtures.ts"; +import { listValue, loadEnv, scalar } from "./fs.ts"; +import { splitEnvAnyGroup } from "./env-groups.ts"; + +type EnvSource = Record; + +export type EnvReadiness = { + status: "ready" | "missing" | "not_required"; + required: string[]; + configured: string[]; + missing: string[]; + values: Record; +}; + +export type AutomationReadiness = EnvReadiness & { + script: string; + defaulted: string[]; + pipeline_env_required: boolean; + env_aliases: Array<{ + target: string; + source: string; + configured: boolean; + }>; +}; + +export type ManualReadiness = { + status: "manual_check" | "not_required"; + preconditions: string[]; + setup: string[]; + cleanup: string[]; +}; + +export type FixtureReadiness = { + status: "ready" | "missing" | "not_required"; + required: Array<{ + id: string; + kind: string; + path: string; + exists: boolean; + }>; + missing: string[]; +}; + +const secretKeyRe = /(?:api[_-]?key|authorization|bearer|credential|jwt|oauth|password|secret|token)/i; + +export function redactEnvValue(key: string, value: string): string { + if (!value) return ""; + if (secretKeyRe.test(key)) return "[redacted]"; + return value.replace(/(https?:\/\/)([^:@/\s]+):([^@/\s]+)@/i, "$1[redacted]@"); +} + +export function runtimeEnv(root: string): Record { + const result: Record = { ...loadEnv(root) }; + for (const [key, value] of Object.entries(processEnv)) { + if (typeof value === "string") result[key] = value; + } + return result; +} + +function envReadiness( + keys: string[], + env: EnvSource, + defaults: Record = {}, + anyGroups: string[] = [], + providedBySetup: Set = new Set(), +): EnvReadiness { + const required = [...keys]; + const configured = required.filter((key) => Boolean(env[key]) || Boolean(defaults[key]) || providedBySetup.has(key)); + const missing = required.filter((key) => !env[key] && !defaults[key] && !providedBySetup.has(key)); + const values: Record = Object.fromEntries( + required.map((key) => [key, redactEnvValue(key, env[key] ?? defaults[key] ?? setupProvidedValue(key, providedBySetup))]), + ); + + for (const group of anyGroups) { + const keysInGroup = splitEnvAnyGroup(group); + required.push(group); + const configuredKeys = keysInGroup.filter((key) => Boolean(env[key]) || Boolean(defaults[key]) || providedBySetup.has(key)); + if (configuredKeys.length === 0) missing.push(group); + else configured.push(...configuredKeys); + for (const key of keysInGroup) { + values[key] = redactEnvValue(key, env[key] ?? defaults[key] ?? setupProvidedValue(key, providedBySetup)); + } + } + + return { + status: required.length === 0 ? "not_required" : missing.length === 0 ? "ready" : "missing", + required, + configured: Array.from(new Set(configured)), + missing, + values, + }; +} + +function setupProvidedValue(key: string, providedBySetup: Set): string { + return providedBySetup.has(key) ? "[provided by setup_automation]" : ""; +} + +export function setupProvidedEnv(item: StructuredItem): Set { + return new Set(listValue(item.fields, "setup_provides_env")); +} + +export function automationEnvDefaults(item: StructuredItem, env: EnvSource = processEnv): Record { + const mapping: Array<[string, string]> = [ + ["automation_prompt", "LANGBOT_E2E_PROMPT"], + ["automation_prompts_json", "LANGBOT_E2E_PROMPTS_JSON"], + ["automation_expected_text", "LANGBOT_E2E_EXPECTED_TEXT"], + ["automation_response_timeout_ms", "LANGBOT_E2E_RESPONSE_TIMEOUT_MS"], + ["automation_stream_output", "LANGBOT_E2E_STREAM_OUTPUT"], + ["automation_image_base64_fixture", "LANGBOT_E2E_IMAGE_BASE64_PATH"], + ["automation_runner_config_patch_json", "LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON"], + ["automation_restore_runner_config", "LANGBOT_E2E_RESTORE_RUNNER_CONFIG"], + ["automation_expected_runner_id", "LANGBOT_E2E_EXPECTED_RUNNER_ID"], + ["automation_reset_debug_chat", "LANGBOT_E2E_RESET_DEBUG_CHAT"], + ["automation_debug_chat_session_type", "LANGBOT_E2E_DEBUG_CHAT_SESSION_TYPE"], + ["automation_debug_chat_response_p95_ms", "LANGBOT_E2E_DEBUG_CHAT_RESPONSE_P95_MS"], + ["automation_debug_chat_max_error_rate", "LANGBOT_E2E_DEBUG_CHAT_MAX_ERROR_RATE"], + ["automation_debug_chat_load_requests", "LANGBOT_DEBUG_CHAT_LOAD_REQUESTS"], + ["automation_debug_chat_load_concurrency", "LANGBOT_DEBUG_CHAT_LOAD_CONCURRENCY"], + ["automation_debug_chat_load_timeout_ms", "LANGBOT_DEBUG_CHAT_LOAD_TIMEOUT_MS"], + ["automation_debug_chat_load_response_p95_ms", "LANGBOT_DEBUG_CHAT_LOAD_RESPONSE_P95_MS"], + ["automation_debug_chat_load_first_response_p95_ms", "LANGBOT_DEBUG_CHAT_LOAD_FIRST_RESPONSE_P95_MS"], + ["automation_debug_chat_load_max_error_rate", "LANGBOT_DEBUG_CHAT_LOAD_MAX_ERROR_RATE"], + ["automation_debug_chat_load_min_error_rate", "LANGBOT_DEBUG_CHAT_LOAD_MIN_ERROR_RATE"], + ["automation_debug_chat_load_min_error_count", "LANGBOT_DEBUG_CHAT_LOAD_MIN_ERROR_COUNT"], + ["automation_debug_chat_load_min_ok_count", "LANGBOT_DEBUG_CHAT_LOAD_MIN_OK_COUNT"], + ["automation_debug_chat_load_min_provider_fault_count", "LANGBOT_DEBUG_CHAT_LOAD_MIN_PROVIDER_FAULT_COUNT"], + ["automation_debug_chat_load_expected_prefix", "LANGBOT_DEBUG_CHAT_LOAD_EXPECTED_PREFIX"], + ["automation_debug_chat_load_prompt_template", "LANGBOT_DEBUG_CHAT_LOAD_PROMPT_TEMPLATE"], + ["automation_debug_chat_load_stream", "LANGBOT_DEBUG_CHAT_LOAD_STREAM"], + ["automation_debug_chat_load_reset", "LANGBOT_DEBUG_CHAT_LOAD_RESET"], + ["automation_debug_chat_load_fail_on_final_mismatch", "LANGBOT_DEBUG_CHAT_LOAD_FAIL_ON_FINAL_MISMATCH"], + ["automation_fake_provider_response_text", "LANGBOT_FAKE_PROVIDER_RESPONSE_TEXT"], + ["automation_fake_provider_first_token_delay_ms", "LANGBOT_FAKE_PROVIDER_FIRST_TOKEN_DELAY_MS"], + ["automation_fake_provider_chunk_delay_ms", "LANGBOT_FAKE_PROVIDER_CHUNK_DELAY_MS"], + ["automation_fake_provider_chunk_count", "LANGBOT_FAKE_PROVIDER_CHUNK_COUNT"], + ["automation_fake_provider_fail_first_n", "LANGBOT_FAKE_PROVIDER_FAIL_FIRST_N"], + ["automation_fake_provider_fail_every_n", "LANGBOT_FAKE_PROVIDER_FAIL_EVERY_N"], + ["automation_fake_provider_fault_status", "LANGBOT_FAKE_PROVIDER_FAULT_STATUS"], + ["automation_fake_provider_fail_after_first_chunk", "LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK"], + ["automation_fake_provider_dynamic_response", "LANGBOT_FAKE_PROVIDER_DYNAMIC_RESPONSE"], + ["automation_filesystem_checks_json", "LANGBOT_E2E_FILESYSTEM_CHECKS_JSON"], + ["automation_plugin_package", "LANGBOT_E2E_PLUGIN_PACKAGE"], + ["automation_expected_plugin_id", "LANGBOT_E2E_EXPECTED_PLUGIN_ID"], + ["automation_expected_tool", "LANGBOT_E2E_EXPECTED_TOOL"], + ]; + const defaults: Record = {}; + for (const [field, envKey] of mapping) { + const value = scalar(item.fields, field); + if (value) defaults[envKey] = expandEnvRefs(value, env); + } + const failurePatterns = listValue(item.fields, "failure_patterns"); + if (failurePatterns.length > 0) defaults.LANGBOT_E2E_FAILURE_SIGNALS = failurePatterns.join("\n"); + return defaults; +} + +function expandEnvRefs(value: string, env: EnvSource): string { + return value.replace(/\$\{([A-Z][A-Z0-9_]*)\}|\$([A-Z][A-Z0-9_]*)/g, (_match, braced, bare) => { + return env[braced || bare] || ""; + }); +} + +export function caseEnvReadiness(item: StructuredItem, env: EnvSource): EnvReadiness { + const aliasSources = new Set(automationEnvAliases(item, env).map((alias) => alias.source)); + const provided = setupProvidedEnv(item); + return envReadiness( + listValue(item.fields, "env").filter((key) => !aliasSources.has(key)), + env, + {}, + listValue(item.fields, "env_any"), + provided, + ); +} + +function automationEnvAliases(item: StructuredItem, env: EnvSource): Array<{ + target: string; + source: string; + configured: boolean; +}> { + const provided = setupProvidedEnv(item); + const mapping: Array<[string, string]> = [ + ["automation_pipeline_url_env", "LANGBOT_E2E_PIPELINE_URL"], + ["automation_pipeline_name_env", "LANGBOT_E2E_PIPELINE_NAME"], + ]; + return mapping + .map(([field, target]) => { + const source = scalar(item.fields, field); + return source ? { target, source, configured: Boolean(env[source]) || provided.has(source) } : null; + }) + .filter((item): item is { target: string; source: string; configured: boolean } => item !== null); +} + +export function automationPipelineEnvRequired(item: StructuredItem): boolean { + return Boolean(scalar(item.fields, "automation_pipeline_url_env") || scalar(item.fields, "automation_pipeline_name_env")); +} + +export function caseAutomationReadiness(item: StructuredItem, env: EnvSource): AutomationReadiness { + const script = scalar(item.fields, "automation"); + const aliases = automationEnvAliases(item, env); + const aliasSources = new Set(aliases.map((alias) => alias.source)); + const defaults = automationEnvDefaults(item, env); + const provided = setupProvidedEnv(item); + const requiredKeys = listValue(item.fields, "automation_env").filter((key) => !aliasSources.has(key)); + const readiness = envReadiness(requiredKeys, env, defaults, listValue(item.fields, "automation_env_any"), provided); + const defaulted = requiredKeys.filter((key) => !env[key] && Boolean(defaults[key])); + const aliasConfigured = aliases.some((alias) => alias.configured); + const aliasMissing = automationPipelineEnvRequired(item) && !aliasConfigured + ? [aliases.map((alias) => alias.source).join("|")] + : []; + const missing = [...readiness.missing, ...aliasMissing].filter(Boolean); + const configured = [ + ...readiness.configured, + ...aliases.filter((alias) => alias.configured).map((alias) => alias.source), + ]; + const values = { + ...readiness.values, + ...Object.fromEntries(aliases.map((alias) => [ + alias.source, + redactEnvValue(alias.source, env[alias.source] ?? setupProvidedValue(alias.source, provided)), + ])), + }; + return { + ...readiness, + status: script ? missing.length === 0 ? "ready" : "missing" : "not_required", + script, + defaulted, + required: [...readiness.required, ...aliases.map((alias) => alias.source)], + configured, + missing, + values, + pipeline_env_required: automationPipelineEnvRequired(item), + env_aliases: aliases, + }; +} + +export function resolvedAutomationEnvOverrides(item: StructuredItem, env: EnvSource): Record { + const overrides: Record = {}; + for (const alias of automationEnvAliases(item, env)) { + const value = env[alias.source]; + if (value) overrides[alias.target] = value; + } + for (const [key, value] of Object.entries(automationEnvDefaults(item, env))) { + overrides[key] = expandEnvRefs(value, env); + } + if (automationPipelineEnvRequired(item)) overrides.LANGBOT_E2E_PIPELINE_REQUIRED = "1"; + return overrides; +} + +export function caseManualReadiness(item: StructuredItem): ManualReadiness { + const preconditions = listValue(item.fields, "preconditions"); + const setup = listValue(item.fields, "setup"); + const cleanup = listValue(item.fields, "cleanup"); + return { + status: preconditions.length > 0 || setup.length > 0 ? "manual_check" : "not_required", + preconditions, + setup, + cleanup, + }; +} + +export function caseFixtureReadiness(root: string, caseId: string): FixtureReadiness { + const fixtures = loadFixtureItems(root).items + .filter((item) => item.related_cases.includes(caseId)) + .map((item) => ({ + id: item.id, + kind: item.kind, + path: item.path, + exists: item.exists, + })); + const missing = fixtures.filter((item) => !item.exists).map((item) => item.id); + return { + status: fixtures.length === 0 ? "not_required" : missing.length === 0 ? "ready" : "missing", + required: fixtures, + missing, + }; +} diff --git a/skills/src/setup-automation.ts b/skills/src/setup-automation.ts new file mode 100644 index 000000000..808c04a21 --- /dev/null +++ b/skills/src/setup-automation.ts @@ -0,0 +1,90 @@ +import { existsSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { listValue } from "./fs.ts"; +import type { StructuredItem } from "./types.ts"; + +export type SetupAutomationSpec = { + entry: string; + kind: "case" | "node"; + target: string; + args: string[]; +}; + +export function setupAutomationEntries(item: StructuredItem): string[] { + return listValue(item.fields, "setup_automation"); +} + +export function parseSetupAutomationEntry(entry: string): SetupAutomationSpec { + const trimmed = entry.trim(); + if (trimmed.startsWith("case:")) { + return { + entry, + kind: "case", + target: trimmed.slice("case:".length).trim(), + args: [], + }; + } + if (trimmed.startsWith("node:")) { + const words = trimmed.slice("node:".length).trim().split(/\s+/).filter(Boolean); + return { + entry, + kind: "node", + target: words[0] ?? "", + args: words.slice(1), + }; + } + return { + entry, + kind: "case", + target: "", + args: [], + }; +} + +export function validateSetupAutomationEntry(root: string, entry: string, caseIds: Set): string[] { + const spec = parseSetupAutomationEntry(entry); + const errors: string[] = []; + if (!entry.startsWith("case:") && !entry.startsWith("node:")) { + return [`setup_automation entry must start with 'case:' or 'node:': ${entry}`]; + } + if (!spec.target) errors.push(`setup_automation entry is missing a target: ${entry}`); + if (spec.kind === "case") { + if (spec.args.length > 0) errors.push(`setup_automation case entries cannot include args: ${entry}`); + if (spec.target && !/^[a-z0-9][a-z0-9_-]*$/.test(spec.target)) { + errors.push(`setup_automation case target must be a case id: ${entry}`); + } else if (spec.target && !caseIds.has(spec.target)) { + errors.push(`setup_automation references unknown case '${spec.target}'`); + } + } + if (spec.kind === "node") { + if (spec.target.startsWith("/") || spec.target.includes("..") || !spec.target.startsWith("scripts/")) { + errors.push(`setup_automation node target must be a repository scripts/ path: ${entry}`); + } + if (spec.target && !/\.(mjs|js|ts)$/.test(spec.target)) { + errors.push(`setup_automation node target must be a Node script: ${entry}`); + } + if (spec.target && !existsSync(join(root, spec.target))) { + errors.push(`setup_automation node script does not exist: ${spec.target}`); + } + for (const arg of spec.args) { + if (!/^--[A-Za-z0-9][A-Za-z0-9_-]*(?:=[A-Za-z0-9_./:@-]+)?$/.test(arg)) { + errors.push(`setup_automation node arg must be a simple --flag or --key=value: ${entry}`); + } + } + } + return errors; +} + +export function setupAutomationEvidenceName(index: number, spec: SetupAutomationSpec): string { + const target = spec.kind === "case" ? spec.target : basename(spec.target).replace(/\.[^.]+$/, ""); + return `${String(index + 1).padStart(2, "0")}-${target.replace(/[^A-Za-z0-9_-]+/g, "-")}`; +} + +export function setupAutomationScriptPath(root: string, spec: SetupAutomationSpec): string { + return spec.kind === "node" && spec.target ? resolve(root, spec.target) : ""; +} + +export function lbsScriptPath(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), "lbs.ts"); +} diff --git a/skills/src/types.ts b/skills/src/types.ts new file mode 100644 index 000000000..ae9264249 --- /dev/null +++ b/skills/src/types.ts @@ -0,0 +1,25 @@ +export type Skill = { + path: string; + directory: string; + name: string; + description: string; + body: string; +}; + +export type CommandContext = { + root: string; + args: string[]; +}; + +export type ParsedYamlValue = string | boolean | string[]; + +export type ParsedYaml = Record; + +export type StructuredItem = { + path: string; + skill: string; + fields: ParsedYaml; + raw: string; +}; + +export type StructuredItemKind = "cases" | "suites" | "troubleshooting"; diff --git a/skills/test/lbs-cli.test.ts b/skills/test/lbs-cli.test.ts new file mode 100644 index 000000000..66c0411f2 --- /dev/null +++ b/skills/test/lbs-cli.test.ts @@ -0,0 +1,3362 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { appendFileSync, chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CommandContext } from "../src/types.ts"; +import { commandCaseList, commandCaseNew, commandCaseShow } from "../src/commands/case.ts"; +import { commandEnvDoctor, commandEnvShow } from "../src/commands/env.ts"; +import { commandFixtureCheck, commandFixtureList } from "../src/commands/fixture.ts"; +import { commandLogGuard, commandLogScan, commandLogWatch } from "../src/commands/log.ts"; +import { commandSuiteList, commandSuiteNew, commandSuitePlan, commandSuiteReport, commandSuiteRun, commandSuiteShow, commandSuiteStart } from "../src/commands/suite.ts"; +import { commandTestPlan, commandTestRecommend, commandTestReport, commandTestResult, commandTestRun, commandTestStart } from "../src/commands/test.ts"; +import { commandTroubleSearch } from "../src/commands/trouble.ts"; +import { commandValidate } from "../src/commands/validate.ts"; +import { commandIndex } from "../src/commands/skill.ts"; +import { loadEnv } from "../src/fs.ts"; +import { repoRoot } from "../src/cli.ts"; +import { + classifyDebugChatResult, + findNewFailureSignal, + minExpectedOccurrences, +} from "../scripts/e2e/lib/debug-chat.mjs"; + +const root = process.cwd(); + +test("repo root detects the skills tree before generated bin exists", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-root-no-bin-")); + try { + mkdirSync(join(tmp, "schemas"), { recursive: true }); + mkdirSync(join(tmp, "skills", "langbot-testing"), { recursive: true }); + writeFileSync(join(tmp, "skills.index.json"), "{}"); + writeFileSync(join(tmp, "schemas", "case.schema.json"), "{}"); + assert.equal(repoRoot(join(tmp, "skills", "langbot-testing")), tmp); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +function ctx(args: string[]): CommandContext { + return { root, args }; +} + +function capture(fn: () => number): { code: number; output: string } { + const originalLog = console.log; + const lines: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }; + try { + const code = fn(); + return { code, output: lines.join("\n") }; + } finally { + console.log = originalLog; + } +} + +function captureAll(fn: () => number): { code: number; output: string; error: string } { + const originalLog = console.log; + const originalWrite = process.stderr.write; + const lines: string[] = []; + const errors: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }; + process.stderr.write = ((chunk: string | Uint8Array) => { + errors.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + const code = fn(); + return { code, output: lines.join("\n"), error: errors.join("") }; + } finally { + console.log = originalLog; + process.stderr.write = originalWrite; + } +} + +function suiteResult(caseId: string, runId: string, status = "pass", evidence = ["ui", "screenshot", "console", "backend_log"]): string { + return JSON.stringify({ + source: "final", + case_id: caseId, + run_id: `${runId}-${caseId}`, + status, + reason: `${caseId} ${status}`, + started_at_local: "2026-05-21T10:30:00.000+08:00", + finished_at_local: "2026-05-21T10:31:00.000+08:00", + evidence_collected: evidence, + }); +} + +function withEnv(values: Record, fn: () => T): T { + const previous = new Map(Object.keys(values).map((key) => [key, process.env[key]])); + try { + for (const [key, value] of Object.entries(values)) process.env[key] = value; + return fn(); + } finally { + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +async function captureAsync(fn: () => Promise): Promise<{ code: number; output: string }> { + const originalLog = console.log; + const lines: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }; + try { + const code = await fn(); + return { code, output: lines.join("\n") }; + } finally { + console.log = originalLog; + } +} + +test("validate accepts the repository assets", () => { + const result = capture(() => commandValidate(root)); + assert.equal(result.code, 0); + assert.match(result.output, /^OK/m); +}); + +test("validate allows blank shared env values but requires declared keys", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-validate-env-template-")); + try { + const schemasDir = join(tmp, "schemas"); + const skillsDir = join(tmp, "skills"); + const testingDir = join(skillsDir, "langbot-testing"); + mkdirSync(schemasDir, { recursive: true }); + mkdirSync(testingDir, { recursive: true }); + for (const schemaName of ["case.schema.json", "suite.schema.json", "troubleshooting.schema.json", "skill-index.schema.json"]) { + writeFileSync(join(schemasDir, schemaName), "{}"); + } + writeFileSync(join(testingDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + const envText = [ + "LANGBOT_FRONTEND_URL=http://127.0.0.1:3000", + "LANGBOT_BACKEND_URL=http://127.0.0.1:5300", + "LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:3000", + "LANGBOT_REPO=", + "LANGBOT_WEB_REPO=", + "LANGBOT_BROWSER_PROFILE=", + "LANGBOT_CHROMIUM_EXECUTABLE=", + ].join("\n"); + writeFileSync(join(skillsDir, ".env"), envText); + writeFileSync(join(skillsDir, ".env.example"), envText); + + const result = capture(() => commandValidate(tmp)); + + assert.equal(result.code, 0); + assert.match(result.output, /^OK/m); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("index includes case summaries for agent discovery", () => { + const result = capture(() => commandIndex({ root, args: ["index"] })); + assert.equal(result.code, 0); + const index = JSON.parse(readFileSync(join(root, "skills.index.json"), "utf8")); + const testing = index.skills.find((skill: { name: string }) => skill.name === "langbot-testing"); + assert.ok(testing); + assert.ok(testing.case_summaries.some((item: { id: string; priority: string; evidence_required: string[] }) => ( + item.id === "pipeline-debug-chat" && item.priority === "p0" && item.evidence_required.includes("backend_log") + ))); + assert.ok(testing.case_summaries.some((item: { id: string; setup_automation: string[]; setup_provides_env: string[] }) => ( + item.id === "agent-runner-qa-debug-chat" && + item.setup_automation.includes("case:agent-runner-live-install") && + item.setup_provides_env.includes("LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL") + ))); + assert.ok(testing.suite_summaries.some((item: { id: string; cases: string[] }) => ( + item.id === "core-smoke" && item.cases.includes("pipeline-debug-chat") + ))); + assert.ok(testing.fixtures.some((item: { id: string; related_cases: string[] }) => ( + item.id === "mcp-stdio-echo-server" && item.related_cases.includes("mcp-stdio-tool-call") + ))); +}); + +test("index check detects stale index without writing", () => { + const path = join(root, "skills.index.json"); + const current = capture(() => commandIndex({ root, args: ["index"] })); + assert.equal(current.code, 0); + + const fresh = readFileSync(path, "utf8"); + try { + const ok = capture(() => commandIndex({ root, args: ["index", "--check"] })); + assert.equal(ok.code, 0); + assert.match(ok.output, /^OK /); + + writeFileSync(path, "{}\n"); + const stale = captureAll(() => commandIndex({ root, args: ["index", "--check"] })); + assert.equal(stale.code, 1); + assert.match(stale.error, /index is stale/); + assert.equal(readFileSync(path, "utf8"), "{}\n"); + } finally { + writeFileSync(path, fresh); + } +}); + +test("case list exposes seeded QA cases", () => { + const result = capture(() => commandCaseList(ctx(["case", "list"]))); + assert.equal(result.code, 0); + assert.match(result.output, /pipeline-debug-chat/); + assert.match(result.output, /provider-deepseek/); + assert.match(result.output, /webui-login-state/); +}); + +test("case list JSON filters by reusable agent-selection metadata", () => { + const result = capture(() => commandCaseList(ctx([ + "case", + "list", + "--json", + "--priority", + "p0", + "--automation", + ]))); + assert.equal(result.code, 0); + const rows = JSON.parse(result.output); + assert.ok(rows.length >= 2); + assert.ok(rows.every((row: { priority: string }) => row.priority === "p0")); + assert.ok(rows.every((row: { automation: string }) => row.automation)); + assert.ok(rows.some((row: { id: string; evidence_required: string[]; readiness: string }) => ( + row.id === "pipeline-debug-chat" && row.evidence_required.includes("backend_log") && row.readiness + ))); +}); + +test("case list distinguishes machine readiness from manual precondition checks", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-case-manual-readiness-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + mkdirSync(join(skillDir, "cases"), { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); + writeFileSync(join(tmp, "skills", ".env"), "LANGBOT_FRONTEND_URL=http://127.0.0.1:3000\n"); + writeFileSync( + join(skillDir, "cases", "manual-case.yaml"), + [ + "id: manual-case", + "title: Manual Case", + "mode: agent-browser", + "area: pipeline", + "type: smoke", + "priority: p2", + "risk: medium", + "ci_eligible: false", + "tags:", + " - smoke", + "skills:", + " - langbot-testing", + "env:", + " - LANGBOT_FRONTEND_URL", + "preconditions:", + " - Confirm the target pipeline is safe to modify.", + "steps:", + " - Open the page.", + "checks:", + " - UI: Page opens.", + "evidence_required:", + " - ui", + ].join("\n"), + ); + + const machineReady = capture(() => commandCaseList({ root: tmp, args: ["case", "list", "--machine-ready"] })); + assert.equal(machineReady.code, 0); + assert.match(machineReady.output, /manual-case/); + assert.match(machineReady.output, /manual-check/); + + const ready = capture(() => commandCaseList({ root: tmp, args: ["case", "list", "--ready"] })); + assert.equal(ready.code, 0); + assert.doesNotMatch(ready.output, /manual-case/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("case show prints structured agent-browser case", () => { + const result = capture(() => commandCaseShow(ctx(["case", "show", "pipeline-debug-chat"]))); + assert.equal(result.code, 0); + assert.match(result.output, /^id: pipeline-debug-chat/m); + assert.match(result.output, /^mode: agent-browser/m); + assert.match(result.output, /^checks:/m); +}); + +test("case new writes required selection metadata", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-case-new-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); + + const result = capture(() => commandCaseNew({ + root: tmp, + args: ["case", "new", "new-case", "--title", "New Case"], + })); + + assert.equal(result.code, 0); + const text = readFileSync(join(skillDir, "cases", "new-case.yaml"), "utf8"); + assert.match(text, /^priority: p2/m); + assert.match(text, /^risk: medium/m); + assert.match(text, /^ci_eligible: false/m); + assert.match(text, /^evidence_required:/m); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite list and plan expose reusable case groups", () => { + const list = capture(() => commandSuiteList(ctx(["suite", "list", "--json", "--priority", "p0"]))); + assert.equal(list.code, 0); + const suites = JSON.parse(list.output); + assert.ok(suites.some((suite: { id: string; cases: string[] }) => ( + suite.id === "core-smoke" && suite.cases.includes("webui-login-state") + ))); + + const plan = capture(() => commandSuitePlan(ctx(["suite", "plan", "core-smoke", "--json"]))); + assert.equal(plan.code, 0); + const suitePlan = JSON.parse(plan.output); + assert.equal(suitePlan.id, "core-smoke"); + assert.ok(suitePlan.cases.some((item: { id: string; evidence_required: string[] }) => ( + item.id === "pipeline-debug-chat" && item.evidence_required.includes("backend_log") + ))); + assert.ok(suitePlan.commands.some((item: { id: string; automation: string }) => ( + item.id === "pipeline-debug-chat" && item.automation.includes("test run") + ))); + + const localAgent = capture(() => commandSuitePlan(ctx(["suite", "plan", "local-agent-gate", "--json"]))); + assert.equal(localAgent.code, 0); + const localAgentPlan = JSON.parse(localAgent.output); + assert.ok(["ready", "missing", "manual_check"].includes(localAgentPlan.readiness.status)); + const basic = localAgentPlan.cases.find((item: { id: string }) => item.id === "local-agent-basic-debug-chat"); + assert.equal(basic.automation_readiness.pipeline_env_required, true); +}); + +test("suite show prints structured suite YAML", () => { + const result = capture(() => commandSuiteShow(ctx(["suite", "show", "local-agent-gate"]))); + assert.equal(result.code, 0); + assert.match(result.output, /^id: local-agent-gate/m); + assert.match(result.output, /^cases:/m); + assert.match(result.output, /local-agent-effective-prompt-debug-chat/); +}); + +test("suite start creates a run handoff with per-case evidence commands", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-start-")); + try { + const evidenceRoot = join(tmp, "evidence"); + const result = capture(() => commandSuiteStart(ctx([ + "suite", + "start", + "core-smoke", + "--run-id", + "core-smoke-local", + "--evidence-dir", + evidenceRoot, + "--json", + ]))); + assert.equal(result.code, 0); + const start = JSON.parse(result.output); + assert.equal(start.suite.id, "core-smoke"); + assert.equal(start.run_id, "core-smoke-local"); + assert.equal(start.evidence_root, evidenceRoot); + assert.equal(start.manifest_path, join(evidenceRoot, "suite-start.json")); + assert.equal(start.handoff_path, join(evidenceRoot, "suite-start.md")); + assert.match(start.report_command, /bin\/lbs suite report core-smoke/); + assert.ok(existsSync(join(evidenceRoot, "suite-start.json"))); + assert.ok(existsSync(join(evidenceRoot, "suite-start.md"))); + const pipeline = start.cases.find((item: { id: string }) => item.id === "pipeline-debug-chat"); + assert.ok(pipeline); + assert.ok(existsSync(join(evidenceRoot, "pipeline-debug-chat"))); + assert.match(pipeline.automation_command, /bin\/lbs test run pipeline-debug-chat/); + assert.match(pipeline.report_command, /--evidence-dir .+pipeline-debug-chat/); + assert.match(pipeline.result_command_template, /bin\/lbs test result pipeline-debug-chat/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite report aggregates case result JSON files", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-report-")); + try { + const evidenceRoot = join(tmp, "suite-evidence"); + const runId = "suite-report-run"; + for (const [caseId, status] of [ + ["webui-login-state", "pass"], + ["pipeline-debug-chat", "pass"], + ["local-agent-basic-debug-chat", "env_issue"], + ]) { + const dir = join(evidenceRoot, caseId); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "result.json"), + suiteResult(caseId, runId, status), + ); + } + + const result = capture(() => commandSuiteReport(ctx([ + "suite", + "report", + "core-smoke", + "--run-id", + runId, + "--evidence-dir", + evidenceRoot, + "--json", + ]))); + + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.status, "env_issue"); + assert.equal(report.counts.pass, 2); + assert.equal(report.counts.env_issue, 1); + assert.ok(report.cases.some((item: { id: string; result: { status: string } }) => ( + item.id === "local-agent-basic-debug-chat" && item.result.status === "env_issue" + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite report treats pass without required evidence as incomplete", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-report-evidence-")); + try { + const evidenceRoot = join(tmp, "suite-evidence"); + const runId = "suite-report-evidence"; + for (const caseId of ["webui-login-state", "pipeline-debug-chat", "local-agent-basic-debug-chat"]) { + const dir = join(evidenceRoot, caseId); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "result.json"), + suiteResult(caseId, runId, "pass", ["ui"]), + ); + } + + const result = capture(() => commandSuiteReport(ctx([ + "suite", + "report", + "core-smoke", + "--run-id", + runId, + "--evidence-dir", + evidenceRoot, + "--json", + ]))); + + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.status, "incomplete"); + assert.ok(report.cases.some((item: { id: string; result: { evidence_missing: string[] } }) => ( + item.id === "pipeline-debug-chat" && item.result.evidence_missing.includes("backend_log") + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite report marks missing case evidence as incomplete", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-report-missing-")); + try { + const evidenceRoot = join(tmp, "suite-evidence"); + const runId = "suite-report-missing"; + mkdirSync(join(evidenceRoot, "webui-login-state"), { recursive: true }); + writeFileSync(join(evidenceRoot, "webui-login-state", "result.json"), suiteResult("webui-login-state", runId, "pass")); + + const result = capture(() => commandSuiteReport(ctx([ + "suite", + "report", + "core-smoke", + "--run-id", + runId, + "--evidence-dir", + evidenceRoot, + "--json", + ]))); + + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.status, "incomplete"); + assert.ok(report.cases.some((item: { id: string; result: { status: string } }) => ( + item.id === "pipeline-debug-chat" && item.result.status === "missing" + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite report rejects result files from the wrong case or run", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-report-mismatch-")); + try { + const evidenceRoot = join(tmp, "suite-evidence"); + const runId = "suite-report-mismatch"; + for (const caseId of ["webui-login-state", "pipeline-debug-chat", "local-agent-basic-debug-chat"]) { + const dir = join(evidenceRoot, caseId); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "result.json"), suiteResult(caseId, runId, "pass")); + } + writeFileSync(join(evidenceRoot, "pipeline-debug-chat", "result.json"), suiteResult("webui-login-state", runId, "pass")); + writeFileSync(join(evidenceRoot, "local-agent-basic-debug-chat", "result.json"), suiteResult("local-agent-basic-debug-chat", "old-run", "pass")); + + const result = capture(() => commandSuiteReport(ctx([ + "suite", + "report", + "core-smoke", + "--run-id", + runId, + "--evidence-dir", + evidenceRoot, + "--json", + ]))); + + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.status, "fail"); + assert.ok(report.cases.some((item: { id: string; result: { status: string; reason: string } }) => ( + item.id === "pipeline-debug-chat" && item.result.status === "invalid" && item.result.reason.includes("case_id mismatch") + ))); + assert.ok(report.cases.some((item: { id: string; result: { status: string; reason: string } }) => ( + item.id === "local-agent-basic-debug-chat" && item.result.status === "invalid" && item.result.reason.includes("run_id mismatch") + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite run executes automated cases and aggregates a verdict", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const suitesDir = join(skillDir, "suites"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(suitesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "one.yaml"), + [ + "id: one", + "title: One", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/pass.mjs", + "evidence_required:", + " - filesystem", + ].join("\n"), + ); + writeFileSync( + join(casesDir, "two.yaml"), + [ + "id: two", + "title: Two", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/pass.mjs", + "evidence_required:", + " - filesystem", + ].join("\n"), + ); + writeFileSync( + join(suitesDir, "mini.yaml"), + [ + "id: mini", + "title: Mini", + "description: Mini suite.", + "type: smoke", + "priority: p2", + "tags:", + " - qa", + "cases:", + " - one", + " - two", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "pass.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify({", + " case_id: process.env.LBS_CASE_ID,", + " run_id: process.env.LBS_RUN_ID,", + " status: 'pass',", + " reason: `${process.env.LBS_CASE_ID} pass`,", + " evidence_collected: ['filesystem']", + "}));", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ status: 'pass' }));", + ].join("\n"), + ); + + const result = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "mini", "--run-id", "mini-run", "--evidence-dir", join(tmp, "evidence"), "--json"], + })); + + assert.equal(result.code, 0); + const payload = JSON.parse(result.output); + assert.equal(payload.report.status, "pass"); + assert.equal(payload.report.counts.pass, 2); + assert.deepEqual(payload.executions.map((item: { status: string }) => item.status), ["ok", "ok"]); + assert.ok(existsSync(join(tmp, "evidence", "one", "result.json"))); + assert.ok(existsSync(join(tmp, "evidence", "two", "result.json"))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite run JSON captures failed case output", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-fail-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const suitesDir = join(skillDir, "suites"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(suitesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "fail-case.yaml"), + [ + "id: fail-case", + "title: Fail Case", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/fail.mjs", + ].join("\n"), + ); + writeFileSync( + join(suitesDir, "mini.yaml"), + [ + "id: mini", + "title: Mini", + "description: Mini suite.", + "type: smoke", + "priority: p2", + "tags:", + " - qa", + "cases:", + " - fail-case", + ].join("\n"), + ); + writeFileSync(join(scriptsDir, "fail.mjs"), "console.error('child failure detail'); process.exit(1);\n"); + + const result = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "mini", "--run-id", "mini-run", "--evidence-dir", join(tmp, "evidence"), "--json"], + })); + + assert.equal(result.code, 1); + const payload = JSON.parse(result.output); + assert.equal(payload.executions[0].status, "nonzero"); + assert.match(payload.executions[0].stderr, /child failure detail/); + assert.equal(payload.report.status, "fail"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite run preserves classified env_issue automation results", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-env-issue-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const suitesDir = join(skillDir, "suites"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(suitesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "env-case.yaml"), + [ + "id: env-case", + "title: Env Case", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/env-issue.mjs", + "evidence_required:", + " - filesystem", + ].join("\n"), + ); + writeFileSync( + join(suitesDir, "mini.yaml"), + [ + "id: mini", + "title: Mini", + "description: Mini suite.", + "type: smoke", + "priority: p2", + "tags:", + " - qa", + "cases:", + " - env-case", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "env-issue.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "const result = {", + " case_id: process.env.LBS_CASE_ID,", + " run_id: process.env.LBS_RUN_ID,", + " status: 'env_issue',", + " reason: 'backend not reachable',", + " evidence_collected: ['filesystem']", + "};", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify(result));", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ ...result, source: 'automation' }));", + "process.exit(2);", + ].join("\n"), + ); + + const result = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "mini", "--run-id", "mini-run", "--evidence-dir", join(tmp, "evidence"), "--json"], + })); + + assert.equal(result.code, 2); + const payload = JSON.parse(result.output); + assert.equal(payload.executions[0].status, "classified"); + assert.equal(payload.report.status, "env_issue"); + assert.equal(payload.report.execution_status, "ok"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite run failure cannot be masked by stale pass result", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-stale-pass-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const suitesDir = join(skillDir, "suites"); + const scriptsDir = join(tmp, "scripts"); + const evidenceDir = join(tmp, "evidence"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(suitesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + mkdirSync(join(evidenceDir, "fail-case"), { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "fail-case.yaml"), + [ + "id: fail-case", + "title: Fail Case", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/fail.mjs", + "evidence_required:", + " - filesystem", + ].join("\n"), + ); + writeFileSync( + join(suitesDir, "mini.yaml"), + [ + "id: mini", + "title: Mini", + "description: Mini suite.", + "type: smoke", + "priority: p2", + "tags:", + " - qa", + "cases:", + " - fail-case", + ].join("\n"), + ); + writeFileSync(join(scriptsDir, "fail.mjs"), "process.exit(1);\n"); + writeFileSync(join(evidenceDir, "fail-case", "result.json"), JSON.stringify({ + case_id: "fail-case", + run_id: "stale-run-fail-case", + status: "pass", + evidence_collected: ["filesystem"], + })); + + const result = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "mini", "--run-id", "stale-run", "--evidence-dir", evidenceDir, "--json"], + })); + + assert.equal(result.code, 1); + const payload = JSON.parse(result.output); + assert.equal(payload.executions[0].status, "nonzero"); + assert.equal(payload.report.status, "fail"); + assert.equal(payload.report.execution_status, "fail"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite run dry-run plans automation without creating evidence", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-dry-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const suitesDir = join(skillDir, "suites"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(suitesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "dry-case.yaml"), + [ + "id: dry-case", + "title: Dry Case", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/fail-if-run.mjs", + ].join("\n"), + ); + writeFileSync( + join(suitesDir, "dry-suite.yaml"), + [ + "id: dry-suite", + "title: Dry Suite", + "description: Dry run suite.", + "type: smoke", + "priority: p2", + "tags:", + " - qa", + "cases:", + " - dry-case", + ].join("\n"), + ); + writeFileSync(join(scriptsDir, "fail-if-run.mjs"), "process.exit(9);\n"); + + const evidenceDir = join(tmp, "evidence"); + const result = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "dry-suite", "--run-id", "dry-run", "--evidence-dir", evidenceDir, "--dry-run", "--json"], + })); + + assert.equal(result.code, 0); + const payload = JSON.parse(result.output); + assert.equal(payload.executions[0].status, "planned"); + assert.match(payload.executions[0].command, /test run dry-case/); + assert.equal(payload.report.status, "incomplete"); + assert.equal(existsSync(evidenceDir), false); + assert.equal(existsSync(join(tmp, "reports", "dry-run.md")), false); + + const markdown = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "dry-suite", "--run-id", "dry-run-markdown", "--evidence-dir", join(tmp, "evidence-md"), "--dry-run"], + })); + assert.equal(markdown.code, 0); + assert.match(markdown.output, /# Suite Report: dry-suite/); + assert.equal(existsSync(join(tmp, "reports", "dry-run-markdown.md")), false); + assert.equal(existsSync(join(tmp, "evidence-md")), false); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite run skips manual-check cases unless explicitly included", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-manual-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const suitesDir = join(skillDir, "suites"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(suitesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "manual-case.yaml"), + [ + "id: manual-case", + "title: Manual Case", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "preconditions:", + " - Confirm this case is safe to run.", + "automation: scripts/pass.mjs", + "evidence_required:", + " - filesystem", + ].join("\n"), + ); + writeFileSync( + join(suitesDir, "manual-suite.yaml"), + [ + "id: manual-suite", + "title: Manual Suite", + "description: Manual check suite.", + "type: smoke", + "priority: p2", + "tags:", + " - qa", + "cases:", + " - manual-case", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "pass.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify({ case_id: process.env.LBS_CASE_ID, run_id: process.env.LBS_RUN_ID, status: 'pass', evidence_collected: ['filesystem'] }));", + ].join("\n"), + ); + + const skipped = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "manual-suite", "--run-id", "manual-run", "--evidence-dir", join(tmp, "evidence"), "--json"], + })); + assert.equal(skipped.code, 1); + const skippedPayload = JSON.parse(skipped.output); + assert.equal(skippedPayload.executions[0].status, "skipped"); + assert.match(skippedPayload.executions[0].reason, /manual_check/); + assert.equal(existsSync(join(tmp, "evidence", "manual-case", "result.json")), false); + + const included = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "manual-suite", "--run-id", "manual-run-included", "--evidence-dir", join(tmp, "evidence-included"), "--include-manual-check", "--json"], + })); + assert.equal(included.code, 0); + const includedPayload = JSON.parse(included.output); + assert.equal(includedPayload.executions[0].status, "ok"); + assert.equal(includedPayload.report.status, "pass"); + assert.ok(existsSync(join(tmp, "evidence-included", "manual-case", "result.json"))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite run skips cases with missing machine readiness unless explicitly included", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-readiness-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const suitesDir = join(skillDir, "suites"); + const fixturesDir = join(skillDir, "fixtures"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(suitesDir, { recursive: true }); + mkdirSync(fixturesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "not-ready-case.yaml"), + [ + "id: not-ready-case", + "title: Not Ready Case", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "env:", + " - LBS_TEST_SUITE_RUN_MISSING_ENV", + "automation_env:", + " - LBS_TEST_SUITE_RUN_MISSING_AUTOMATION_ENV", + "automation: scripts/pass.mjs", + "evidence_required:", + " - filesystem", + ].join("\n"), + ); + writeFileSync( + join(fixturesDir, "fixtures.json"), + `${JSON.stringify([{ + id: "missing-fixture", + title: "Missing fixture", + kind: "file", + path: "fixtures/missing.txt", + related_cases: ["not-ready-case"], + checks: ["exists"], + }], null, 2)}\n`, + ); + writeFileSync( + join(suitesDir, "readiness-suite.yaml"), + [ + "id: readiness-suite", + "title: Readiness Suite", + "description: Readiness suite.", + "type: smoke", + "priority: p2", + "tags:", + " - qa", + "cases:", + " - not-ready-case", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "pass.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify({ case_id: process.env.LBS_CASE_ID, run_id: process.env.LBS_RUN_ID, status: 'pass', evidence_collected: ['filesystem'] }));", + ].join("\n"), + ); + + const skipped = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "readiness-suite", "--run-id", "readiness-run", "--evidence-dir", join(tmp, "evidence"), "--json"], + })); + assert.equal(skipped.code, 1); + const skippedPayload = JSON.parse(skipped.output); + assert.equal(skippedPayload.executions[0].status, "skipped"); + assert.match(skippedPayload.executions[0].reason, /readiness missing/); + assert.match(skippedPayload.executions[0].reason, /LBS_TEST_SUITE_RUN_MISSING_ENV/); + assert.match(skippedPayload.executions[0].reason, /LBS_TEST_SUITE_RUN_MISSING_AUTOMATION_ENV/); + assert.match(skippedPayload.executions[0].reason, /missing-fixture/); + assert.equal(existsSync(join(tmp, "evidence", "not-ready-case", "result.json")), false); + + const included = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "readiness-suite", "--run-id", "readiness-run-included", "--evidence-dir", join(tmp, "evidence-included"), "--include-not-ready", "--json"], + })); + assert.equal(included.code, 0); + const includedPayload = JSON.parse(included.output); + assert.equal(includedPayload.executions[0].status, "ok"); + assert.equal(includedPayload.report.status, "pass"); + assert.ok(existsSync(join(tmp, "evidence-included", "not-ready-case", "result.json"))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite new writes a reusable suite skeleton", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-new-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); + + const result = capture(() => commandSuiteNew({ + root: tmp, + args: ["suite", "new", "new-suite", "--title", "New Suite"], + })); + + assert.equal(result.code, 0); + const text = readFileSync(join(skillDir, "suites", "new-suite.yaml"), "utf8"); + assert.match(text, /^description:/m); + assert.match(text, /^priority: p2/m); + assert.match(text, /^cases:/m); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("fixture list and check expose reusable fixture readiness", () => { + const list = capture(() => commandFixtureList(ctx(["fixture", "list", "langbot-testing", "--json"]))); + assert.equal(list.code, 0); + const fixtures = JSON.parse(list.output); + assert.ok(fixtures.some((item: { id: string; exists: boolean }) => ( + item.id === "mcp-stdio-echo-server" && item.exists === true + ))); + + const check = capture(() => commandFixtureCheck(ctx(["fixture", "check", "langbot-testing", "--json"]))); + assert.equal(check.code, 0); + const report = JSON.parse(check.output); + assert.equal(report.status, "pass"); + assert.ok(report.fixtures.some((item: { id: string }) => item.id === "qa-plugin-smoke-package")); +}); + +test("fixture check reports missing manifest paths", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-fixture-check-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + mkdirSync(join(skillDir, "fixtures"), { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); + writeFileSync( + join(skillDir, "fixtures", "fixtures.json"), + JSON.stringify([{ id: "missing-fixture", title: "Missing Fixture", path: "fixtures/missing.txt" }]), + ); + + const result = capture(() => commandFixtureCheck({ root: tmp, args: ["fixture", "check", "langbot-testing", "--json"] })); + + assert.equal(result.code, 1); + const report = JSON.parse(result.output); + assert.equal(report.status, "fail"); + assert.ok(report.findings.some((finding: { id?: string }) => finding.id === "missing-fixture")); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("fixture check verifies QA AgentRunner source shape", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-fixture-check-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const fixtureDir = join(skillDir, "fixtures", "plugins", "qa-agent-runner"); + mkdirSync(join(fixtureDir, "components", "agent_runner"), { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); + writeFileSync( + join(skillDir, "fixtures", "fixtures.json"), + JSON.stringify([{ + id: "qa-agent-runner-source", + title: "QA AgentRunner", + path: "fixtures/plugins/qa-agent-runner/manifest.yaml", + checks: ["exists", "qa_agent_runner_source"], + }]), + ); + writeFileSync(join(fixtureDir, "manifest.yaml"), "spec:\n components:\n AgentRunner: {}\nexecution:\n python:\n attr: QAAgentRunnerPlugin\n"); + + const result = capture(() => commandFixtureCheck({ root: tmp, args: ["fixture", "check", "langbot-testing", "--json"] })); + + assert.equal(result.code, 1); + const report = JSON.parse(result.output); + assert.ok(report.findings.some((finding: { kind?: string; path?: string }) => ( + finding.kind === "fixture_check_missing_file" + && finding.path?.endsWith("components/agent_runner/default.py") + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("fixture check accepts complete QA AgentRunner source shape", () => { + const result = capture(() => commandFixtureCheck(ctx(["fixture", "check", "langbot-testing", "--json"]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.ok(report.fixtures.some((item: { id: string; checks: string[] }) => ( + item.id === "qa-agent-runner-source" && item.checks.includes("qa_agent_runner_source") + ))); +}); + +test("fixture check rejects invalid plugin package files", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-fixture-check-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + mkdirSync(join(skillDir, "fixtures"), { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); + writeFileSync(join(skillDir, "fixtures", "bad.lbpkg"), "not a zip"); + writeFileSync( + join(skillDir, "fixtures", "fixtures.json"), + JSON.stringify([{ + id: "bad-package", + title: "Bad Package", + path: "fixtures/bad.lbpkg", + checks: ["exists", "zip_package"], + }]), + ); + + const result = capture(() => commandFixtureCheck({ root: tmp, args: ["fixture", "check", "langbot-testing", "--json"] })); + + assert.equal(result.code, 1); + const report = JSON.parse(result.output); + assert.ok(report.findings.some((finding: { kind?: string; id?: string }) => ( + finding.kind === "fixture_check_invalid_zip" && finding.id === "bad-package" + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("debug chat classifier prefers latest response leaf over body counts", () => { + const result = classifyDebugChatResult({ + beforeText: "OK from an older chat", + afterText: "OK from an older chat\nUser: say OK\nBot: OK", + expectedText: "OK", + prompt: "say OK", + latestExpectedLeaf: "Bot: OK", + latestFailureLeaf: "", + }); + + assert.equal(result.status, "pass"); + assert.match(result.reason, /latest visible response leaf/); +}); + +test("debug chat classifier distinguishes new failure signals from old history", () => { + assert.equal( + findNewFailureSignal("Agent runner temporarily unavailable", "Agent runner temporarily unavailable"), + "", + ); + assert.equal( + findNewFailureSignal("", "Agent runner temporarily unavailable"), + "Agent runner temporarily unavailable", + ); + + const result = classifyDebugChatResult({ + beforeText: "", + afterText: "Agent runner temporarily unavailable", + expectedText: "OK", + prompt: "say OK", + latestExpectedLeaf: "", + latestFailureLeaf: "Agent runner temporarily unavailable", + }); + + assert.equal(result.status, "fail"); + assert.match(result.reason, /known failure signal/); + + const custom = classifyDebugChatResult({ + beforeText: "", + afterText: "Bot: qa-plugin-smoke:mcp-ok-local-agent", + expectedText: "qa_mcp_echo:mcp-ok-local-agent", + prompt: "call mcp", + latestExpectedLeaf: "", + latestFailureLeaf: "Bot: qa-plugin-smoke:mcp-ok-local-agent", + failureSignals: ["qa-plugin-smoke:mcp-ok-local-agent"], + }); + + assert.equal(custom.status, "fail"); + assert.equal(custom.failure_signal, "qa-plugin-smoke:mcp-ok-local-agent"); +}); + +test("debug chat classifier lets new failure signals override stale expected history", () => { + const result = classifyDebugChatResult({ + beforeText: "Bot: qa_mcp_echo:mcp-ok-local-agent", + afterText: [ + "Bot: qa_mcp_echo:mcp-ok-local-agent", + "User: Call qa_mcp_echo", + "Bot: Agent runner temporarily unavailable.", + ].join("\n"), + expectedText: "qa_mcp_echo:mcp-ok-local-agent", + prompt: "Call qa_mcp_echo", + latestExpectedLeaf: "Bot: qa_mcp_echo:mcp-ok-local-agent", + latestFailureLeaf: "Bot: Agent runner temporarily unavailable.", + }); + + assert.equal(result.status, "fail"); + assert.equal(result.failure_signal, "Agent runner temporarily unavailable"); +}); + +test("debug chat classifier does not pass on stale expected history without a new occurrence", () => { + const result = classifyDebugChatResult({ + beforeText: "Bot: qa_mcp_echo:mcp-ok-local-agent", + afterText: [ + "Bot: qa_mcp_echo:mcp-ok-local-agent", + "User: Call qa_mcp_echo", + ].join("\n"), + expectedText: "qa_mcp_echo:mcp-ok-local-agent", + prompt: "Call qa_mcp_echo", + latestExpectedLeaf: "Bot: qa_mcp_echo:mcp-ok-local-agent", + latestFailureLeaf: "", + }); + + assert.equal(result.status, "fail"); + assert.equal(result.final_count, 1); + assert.equal(result.min_expected_count, 2); +}); + +test("debug chat classifier accounts for prompt echo occurrences", () => { + assert.equal(minExpectedOccurrences("", "OK", "say OK"), 2); + const result = classifyDebugChatResult({ + beforeText: "", + afterText: "User: say OK", + expectedText: "OK", + prompt: "say OK", + latestExpectedLeaf: "User: say OK", + latestFailureLeaf: "", + }); + + assert.equal(result.status, "fail"); + assert.equal(result.min_expected_count, 2); + assert.equal(result.final_count, 1); +}); + +test("debug chat classifier requires new assistant evidence when message bubbles are available", () => { + const prompt = "If all steps succeed, final answer must be E2E_OK:skill"; + const result = classifyDebugChatResult({ + beforeText: "", + afterText: `User: ${prompt}`, + expectedText: "E2E_OK:skill", + prompt, + latestExpectedLeaf: prompt, + latestFailureLeaf: "", + beforeMessages: [], + afterMessages: [{ role: "user", text: prompt }], + latestAssistantText: "", + }); + + assert.equal(result.status, "fail"); + assert.match(result.reason, /new assistant message/); + assert.equal(result.before_assistant_expected_count, 0); + assert.equal(result.after_assistant_expected_count, 0); +}); + +test("debug chat classifier passes when expected text appears in a new assistant message", () => { + const prompt = "Return only E2E_OK:skill"; + const result = classifyDebugChatResult({ + beforeText: "", + afterText: `User: ${prompt}\nBot: E2E_OK:skill`, + expectedText: "E2E_OK:skill", + prompt, + latestExpectedLeaf: "E2E_OK:skill", + latestFailureLeaf: "", + beforeMessages: [], + afterMessages: [ + { role: "user", text: prompt }, + { role: "assistant", text: "E2E_OK:skill" }, + ], + latestAssistantText: "E2E_OK:skill", + }); + + assert.equal(result.status, "pass"); + assert.match(result.reason, /new assistant message/); + assert.equal(result.before_assistant_expected_count, 0); + assert.equal(result.after_assistant_expected_count, 1); +}); + +test("debug chat classifier allows a recovered failure when latest assistant is successful", () => { + const expectedText = "E2E_OK:skill"; + const result = classifyDebugChatResult({ + beforeText: "", + afterText: [ + "Bot: Agent runner temporarily unavailable", + `Bot: recovered and completed ${expectedText}`, + ].join("\n"), + expectedText, + prompt: "Modify the existing skill", + latestExpectedLeaf: `recovered and completed ${expectedText}`, + latestFailureLeaf: "Agent runner temporarily unavailable", + beforeMessages: [], + afterMessages: [ + { role: "assistant", text: "Agent runner temporarily unavailable" }, + { role: "assistant", text: `recovered and completed ${expectedText}` }, + ], + latestAssistantText: `recovered and completed ${expectedText}`, + }); + + assert.equal(result.status, "pass"); + assert.equal(result.before_assistant_expected_count, 0); + assert.equal(result.after_assistant_expected_count, 1); +}); + +test("env doctor explains a missing backend listener with a startup hint", async () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-env-doctor-")); + try { + const skillsDir = join(tmp, "skills"); + const repoDir = join(tmp, "LangBot"); + const webDir = join(repoDir, "web"); + const browserProfile = join(tmp, "browser-profile"); + const chromium = join(tmp, "chromium"); + mkdirSync(skillsDir, { recursive: true }); + mkdirSync(webDir, { recursive: true }); + mkdirSync(browserProfile, { recursive: true }); + writeFileSync(chromium, ""); + writeFileSync( + join(skillsDir, ".env"), + [ + "LANGBOT_BACKEND_URL=http://127.0.0.1:59998", + "LANGBOT_FRONTEND_URL=http://127.0.0.1:59998", + "LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:59998", + `LANGBOT_REPO=${repoDir}`, + `LANGBOT_WEB_REPO=${webDir}`, + `LANGBOT_BROWSER_PROFILE=${browserProfile}`, + `LANGBOT_CHROMIUM_EXECUTABLE=${chromium}`, + "LANGBOT_PROXY_HTTP=http://127.0.0.1:7890", + "LANGBOT_PROXY_SOCKS=socks5://127.0.0.1:7890", + "LANGBOT_NO_PROXY=localhost,127.0.0.1,::1", + ].join("\n"), + ); + + const result = await captureAsync(() => commandEnvDoctor({ root: tmp, args: ["env", "doctor"] })); + + assert.equal(result.code, 1); + assert.match(result.output, /FAIL: LANGBOT_BACKEND_URL: no HTTP service reachable because 127\.0\.0\.1:59998 is not listening/); + assert.match(result.output, new RegExp(`WARN: LANGBOT_BACKEND_URL: start backend: cd ${repoDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} && uv run main.py`)); + assert.match(result.output, new RegExp(`WARN: LANGBOT_FRONTEND_URL: start frontend: cd ${webDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} && pnpm dev`)); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("env doctor does not require proxy variables", async () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-env-doctor-no-proxy-")); + try { + const skillsDir = join(tmp, "skills"); + const repoDir = join(tmp, "LangBot"); + const webDir = join(repoDir, "web"); + const browserProfile = join(tmp, "browser-profile"); + const chromium = join(tmp, "chromium"); + mkdirSync(skillsDir, { recursive: true }); + mkdirSync(webDir, { recursive: true }); + mkdirSync(browserProfile, { recursive: true }); + writeFileSync(chromium, ""); + writeFileSync( + join(skillsDir, ".env"), + [ + "LANGBOT_BACKEND_URL=http://127.0.0.1:59997", + "LANGBOT_FRONTEND_URL=http://127.0.0.1:59997", + "LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:59997", + `LANGBOT_REPO=${repoDir}`, + `LANGBOT_WEB_REPO=${webDir}`, + `LANGBOT_BROWSER_PROFILE=${browserProfile}`, + `LANGBOT_CHROMIUM_EXECUTABLE=${chromium}`, + ].join("\n"), + ); + + const result = await captureAsync(() => commandEnvDoctor({ root: tmp, args: ["env", "doctor"] })); + + assert.equal(result.code, 1); + assert.doesNotMatch(result.output, /missing LANGBOT_PROXY|missing LANGBOT_NO_PROXY/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("env doctor reports missing socksio for active SOCKS proxy", async () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-env-doctor-socksio-")); + const originalAllProxy = process.env.ALL_PROXY; + const originalAllProxyLower = process.env.all_proxy; + try { + delete process.env.ALL_PROXY; + delete process.env.all_proxy; + const skillsDir = join(tmp, "skills"); + const repoDir = join(tmp, "LangBot"); + const webDir = join(repoDir, "web"); + const venvBin = join(repoDir, ".venv", "bin"); + const browserProfile = join(tmp, "browser-profile"); + const chromium = join(tmp, "chromium"); + mkdirSync(skillsDir, { recursive: true }); + mkdirSync(webDir, { recursive: true }); + mkdirSync(venvBin, { recursive: true }); + mkdirSync(browserProfile, { recursive: true }); + writeFileSync(chromium, ""); + const python = join(venvBin, "python"); + writeFileSync(python, "#!/bin/sh\nexit 1\n"); + chmodSync(python, 0o755); + writeFileSync( + join(skillsDir, ".env"), + [ + "LANGBOT_BACKEND_URL=http://127.0.0.1:59996", + "LANGBOT_FRONTEND_URL=http://127.0.0.1:59996", + "LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:59996", + `LANGBOT_REPO=${repoDir}`, + `LANGBOT_WEB_REPO=${webDir}`, + `LANGBOT_BROWSER_PROFILE=${browserProfile}`, + `LANGBOT_CHROMIUM_EXECUTABLE=${chromium}`, + "ALL_PROXY=socks5://127.0.0.1:7890", + ].join("\n"), + ); + + const result = await captureAsync(() => commandEnvDoctor({ root: tmp, args: ["env", "doctor"] })); + + assert.equal(result.code, 1); + assert.match(result.output, /FAIL: SOCKS proxy ALL_PROXY is configured/); + assert.match(result.output, /cannot import socksio/); + assert.match(result.output, /-m pip install socksio/); + } finally { + if (originalAllProxy === undefined) delete process.env.ALL_PROXY; + else process.env.ALL_PROXY = originalAllProxy; + if (originalAllProxyLower === undefined) delete process.env.all_proxy; + else process.env.all_proxy = originalAllProxyLower; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("env show redacts secret-like values by default", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-env-show-redact-")); + try { + mkdirSync(join(tmp, "skills"), { recursive: true }); + writeFileSync( + join(tmp, "skills", ".env"), + [ + "LANGBOT_FRONTEND_URL=http://127.0.0.1:3000", + "LANGBOT_API_KEY=sk-test-secret", + "LANGBOT_PROXY_HTTP=http://user:pass@127.0.0.1:7890", + ].join("\n"), + ); + + const text = capture(() => commandEnvShow({ root: tmp, args: ["env", "show"] })); + assert.equal(text.code, 0); + assert.match(text.output, /LANGBOT_API_KEY=\[redacted\]/); + assert.match(text.output, /LANGBOT_PROXY_HTTP=http:\/\/\[redacted\]@127\.0\.0\.1:7890/); + assert.doesNotMatch(text.output, /sk-test-secret|user:pass/); + + const json = capture(() => commandEnvShow({ root: tmp, args: ["env", "show", "--json"] })); + assert.equal(json.code, 0); + const parsed = JSON.parse(json.output); + assert.equal(parsed.LANGBOT_API_KEY, "[redacted]"); + assert.equal(parsed.LANGBOT_PROXY_HTTP, "http://[redacted]@127.0.0.1:7890"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test plan renders agent-browser QA guidance", () => { + const result = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat"]))); + assert.equal(result.code, 0); + assert.match(result.output, /Mode: agent-browser/); + assert.match(result.output, /Use browser\/UI interaction as the primary QA path/); + assert.match(result.output, /API\/curl\/log checks are diagnostic only/); + assert.match(result.output, /## Browser Steps/); + assert.match(result.output, /## Success Signals/); + assert.match(result.output, /## Required Evidence/); + assert.match(result.output, /## Automation Readiness/); + assert.match(result.output, /## Fixture Readiness/); + assert.match(result.output, /## Manual Readiness/); + assert.match(result.output, /backend_log/); +}); + +test("test plan JSON is parseable and includes troubleshooting patterns", () => { + const result = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat", "--json"]))); + assert.equal(result.code, 0); + const plan = JSON.parse(result.output); + assert.equal(plan.id, "pipeline-debug-chat"); + assert.equal(plan.mode, "agent-browser"); + assert.ok(["ready", "missing"].includes(plan.automation_readiness.status)); + assert.ok(plan.automation_readiness.defaulted.includes("LANGBOT_E2E_PROMPT")); + assert.ok(plan.automation_readiness.defaulted.includes("LANGBOT_E2E_EXPECTED_TEXT")); + assert.equal(plan.manual_readiness.status, "manual_check"); + assert.ok(plan.success_patterns.includes("Streaming completed")); + assert.ok(plan.troubleshooting.some((entry: { id: string }) => entry.id === "plugin-runtime-timeout")); +}); + +test("test plan JSON exposes missing case-specific pipeline readiness", () => { + const result = capture(() => commandTestPlan(ctx(["test", "plan", "local-agent-basic-debug-chat", "--json"]))); + assert.equal(result.code, 0); + const plan = JSON.parse(result.output); + assert.equal(plan.env_readiness.status, "ready"); + assert.ok(["ready", "missing"].includes(plan.automation_readiness.status)); + assert.ok(plan.automation_readiness.pipeline_env_required); + assert.ok( + plan.automation_readiness.missing.includes("LANGBOT_LOCAL_AGENT_PIPELINE_URL|LANGBOT_LOCAL_AGENT_PIPELINE_NAME") + || plan.automation_readiness.configured.some((key: string) => key.startsWith("LANGBOT_LOCAL_AGENT_PIPELINE_")), + ); +}); + +test("generic pipeline readiness accepts either URL or name target", () => { + const originalUrl = process.env.LANGBOT_PIPELINE_URL; + const originalName = process.env.LANGBOT_PIPELINE_NAME; + try { + withEnv({ + LANGBOT_BROWSER_PROFILE: "/tmp/langbot-test-profile", + LANGBOT_CHROMIUM_EXECUTABLE: "/tmp/langbot-test-chromium", + }, () => { + process.env.LANGBOT_PIPELINE_URL = "http://127.0.0.1:3000/home/pipelines?id=only-url"; + process.env.LANGBOT_PIPELINE_NAME = ""; + + const ready = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat", "--json"]))); + assert.equal(ready.code, 0); + const plan = JSON.parse(ready.output); + assert.equal(plan.env_readiness.status, "ready"); + assert.equal(plan.automation_readiness.status, "ready"); + assert.ok(plan.automation_readiness.required.includes("LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME")); + }); + + process.env.LANGBOT_PIPELINE_URL = ""; + process.env.LANGBOT_PIPELINE_NAME = ""; + + const missing = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat", "--json"]))); + assert.equal(missing.code, 0); + const missingPlan = JSON.parse(missing.output); + assert.equal(missingPlan.env_readiness.status, "missing"); + assert.ok(missingPlan.env_readiness.missing.includes("LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME")); + assert.equal(missingPlan.automation_readiness.status, "missing"); + assert.ok(missingPlan.automation_readiness.missing.includes("LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME")); + } finally { + if (originalUrl === undefined) delete process.env.LANGBOT_PIPELINE_URL; + else process.env.LANGBOT_PIPELINE_URL = originalUrl; + if (originalName === undefined) delete process.env.LANGBOT_PIPELINE_NAME; + else process.env.LANGBOT_PIPELINE_NAME = originalName; + } +}); + +test("test recommend maps AgentRunner ledger changes to focused probes", () => { + const result = capture(() => commandTestRecommend(ctx([ + "test", + "recommend", + "--file", + "LangBot/src/langbot/pkg/agent/runner/run_ledger_store.py", + "--file", + "LangBot/tests/unit_tests/agent/test_run_ledger_store.py", + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + const ids = report.recommendations.map((item: { id: string }) => item.id); + assert.ok(ids.includes("agent-runner-ledger-invariants")); + assert.ok(ids.includes("agent-runner-ledger-stress")); + assert.ok(ids.includes("agent-runner-ledger-contention")); + assert.ok(ids.includes("agent-runner-async-db-readiness")); + assert.ok(ids.includes("agent-runner-ledger-concurrency")); + assert.ok(report.commands.every((command: string) => !command.startsWith("bin/lbs test run ") || command.endsWith(" --dry-run"))); + assert.ok(report.notes.some((note: string) => note.includes("Remove --dry-run"))); +}); + +test("test recommend maps AgentRunner result changes to fixture contract", () => { + const result = capture(() => commandTestRecommend(ctx([ + "test", + "recommend", + "--file", + "langbot-plugin-sdk/src/langbot_plugin/api/entities/builtin/agent_runner/result.py", + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + const ids = report.recommendations.map((item: { id: string }) => item.id); + assert.ok(ids.includes("agent-runner-fixture-contract")); + assert.ok(ids.includes("agent-runner-behavior-matrix")); + assert.ok(!ids.includes("agent-runner-ledger-invariants")); +}); + +test("test recommend maps QA AgentRunner fixture changes to live install", () => { + const result = capture(() => commandTestRecommend(ctx([ + "test", + "recommend", + "--file", + "langbot-skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.py", + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + const ids = report.recommendations.map((item: { id: string }) => item.id); + assert.ok(ids.includes("agent-runner-fixture-contract")); + assert.ok(ids.includes("agent-runner-live-install")); + assert.ok(ids.includes("agent-runner-qa-debug-chat")); +}); + +test("test recommend maps QA plugin smoke fixture changes to live install", () => { + const result = capture(() => commandTestRecommend(ctx([ + "test", + "recommend", + "--file", + "langbot-skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/main.py", + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + const ids = report.recommendations.map((item: { id: string }) => item.id); + assert.ok(ids.includes("qa-plugin-smoke-live-install")); +}); + +test("test recommend keeps git status paths intact", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-recommend-git-")); + const originalRepos = { + LANGBOT_REPO: process.env.LANGBOT_REPO, + LANGBOT_PLUGIN_SDK_REPO: process.env.LANGBOT_PLUGIN_SDK_REPO, + LANGBOT_AGENT_RUNNER_REPO: process.env.LANGBOT_AGENT_RUNNER_REPO, + LANGBOT_LOCAL_AGENT_REPO: process.env.LANGBOT_LOCAL_AGENT_REPO, + }; + try { + const repo = join(tmp, "LangBot"); + mkdirSync(join(repo, "src", "langbot", "pkg", "agent", "runner"), { recursive: true }); + spawnSync("git", ["init"], { cwd: repo }); + spawnSync("git", ["config", "user.email", "qa@example.test"], { cwd: repo }); + spawnSync("git", ["config", "user.name", "QA"], { cwd: repo }); + writeFileSync(join(repo, "README.md"), "test\n"); + writeFileSync(join(repo, "src", "langbot", "pkg", "agent", "runner", "run_ledger_store.py"), "# test\n"); + spawnSync("git", ["add", "README.md", "src/langbot/pkg/agent/runner/run_ledger_store.py"], { cwd: repo }); + spawnSync("git", ["commit", "-m", "init"], { cwd: repo }); + writeFileSync(join(repo, "src", "langbot", "pkg", "agent", "runner", "run_ledger_store.py"), "# changed\n"); + + process.env.LANGBOT_REPO = repo; + process.env.LANGBOT_PLUGIN_SDK_REPO = join(tmp, "missing-sdk"); + process.env.LANGBOT_AGENT_RUNNER_REPO = join(tmp, "missing-runner"); + process.env.LANGBOT_LOCAL_AGENT_REPO = join(tmp, "missing-local"); + const result = capture(() => commandTestRecommend({ root, args: ["test", "recommend", "--json"] })); + + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.ok(report.changed_files.includes("LangBot/src/langbot/pkg/agent/runner/run_ledger_store.py")); + assert.ok(!report.changed_files.some((file: string) => file.includes("LangBot/rc/"))); + } finally { + for (const [key, value] of Object.entries(originalRepos)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test start creates a run handoff with a bounded report command", () => { + const result = capture(() => commandTestStart(ctx(["test", "start", "pipeline-debug-chat"]))); + assert.equal(result.code, 0); + assert.match(result.output, /^# Test Start: pipeline-debug-chat/m); + assert.match(result.output, /bin\/lbs test plan pipeline-debug-chat/); + assert.match(result.output, /bin\/lbs test run pipeline-debug-chat --run-id .+ --output reports\/evidence\/.+pipeline-debug-chat/); + assert.match(result.output, /bin\/lbs test report pipeline-debug-chat --since ".+" --console-log reports\/evidence\/.+\/console\.log --evidence-dir reports\/evidence\/.+ --output reports\/.+pipeline-debug-chat\.md/); + assert.match(result.output, /Streaming completed/); +}); + +test("test start JSON is parseable for agent orchestration", () => { + const result = capture(() => commandTestStart(ctx(["test", "start", "pipeline-debug-chat", "--json"]))); + assert.equal(result.code, 0); + const start = JSON.parse(result.output); + assert.equal(start.case.id, "pipeline-debug-chat"); + assert.match(start.run_id, /pipeline-debug-chat$/); + assert.match(start.started_at_local, /\d{4}-\d{2}-\d{2}T/); + assert.match(start.report_command, /--since/); + assert.match(start.result_command_template, /bin\/lbs test result pipeline-debug-chat/); + assert.match(start.automation.command, /bin\/lbs test run pipeline-debug-chat/); + assert.ok(start.success_patterns.includes("Streaming completed")); + assert.ok(start.evidence_required.includes("backend_log")); +}); + +test("test result writes a suite-readable result.json and enforces pass evidence", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-test-result-")); + try { + const evidenceDir = join(tmp, "pipeline-run"); + const ok = capture(() => commandTestResult(ctx([ + "test", + "result", + "pipeline-debug-chat", + "--result", + "pass", + "--reason", + "Debug Chat returned OK and logs were clean.", + "--evidence-dir", + evidenceDir, + "--started-at", + "2026-05-21T10:30:00.000+08:00", + "--evidence", + "ui,screenshot,console,backend_log", + "--json", + ]))); + + assert.equal(ok.code, 0); + const record = JSON.parse(ok.output); + assert.equal(record.source, "final"); + assert.equal(record.status, "pass"); + assert.equal(record.evidence_status, "complete"); + assert.deepEqual(record.evidence_missing, []); + assert.equal(JSON.parse(readFileSync(join(evidenceDir, "result.json"), "utf8")).case_id, "pipeline-debug-chat"); + + const missing = captureAll(() => commandTestResult(ctx([ + "test", + "result", + "pipeline-debug-chat", + "--result", + "pass", + "--reason", + "Missing backend evidence should not be accepted as pass.", + "--evidence-dir", + join(tmp, "missing-evidence"), + "--evidence", + "ui", + ]))); + assert.equal(missing.code, 1); + assert.match(missing.error, /missing required evidence/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test run dry-run exposes case automation script and evidence paths", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "pipeline-debug-chat", + "--run-id", + "run-123", + "--output", + "reports/evidence/run-123", + "--dry-run", + ]))); + assert.equal(result.code, 0); + assert.match(result.output, /^# Test Automation: pipeline-debug-chat/m); + assert.match(result.output, /scripts\/e2e\/pipeline-debug-chat\.mjs/); + assert.match(result.output, /console_log: reports\/evidence\/run-123\/console\.log/); + assert.match(result.output, /automation_result_json: reports\/evidence\/run-123\/automation-result\.json/); + assert.match(result.output, /result_json: reports\/evidence\/run-123\/result\.json/); + assert.match(result.output, /LANGBOT_PIPELINE_URL/); +}); + +test("test run dry-run JSON is parseable for automation orchestration", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "webui-login-state", + "--run-id", + "login-run", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.equal(run.case.id, "webui-login-state"); + assert.equal(run.run_id, "login-run"); + assert.equal(run.automation.script, "scripts/e2e/webui-login-state.mjs"); + assert.equal(run.automation.exists, true); + assert.match(run.automation.automation_result_json, /automation-result\.json$/); + assert.match(run.automation.result_json, /result\.json$/); + assert.match(run.automation.report_command, /--console-log/); +}); + +test("test run JSON executes automation unless dry-run is explicit", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-run-json-exec-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "json-exec.yaml"), + [ + "id: json-exec", + "title: JSON Exec", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/write-marker.mjs", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "write-marker.mjs"), + [ + "import { writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "writeFileSync(join(process.env.LBS_ROOT, 'json-ran.txt'), 'yes');", + ].join("\n"), + ); + + const result = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "json-exec", "--run-id", "json-run", "--json"], + })); + + assert.equal(result.code, 0); + assert.equal(readFileSync(join(tmp, "json-ran.txt"), "utf8"), "yes"); + const payload = JSON.parse(result.output); + assert.equal(payload.exit_status, 0); + assert.equal(payload.automation_execution.status, "ok"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test run lets explicit environment override automation defaults", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-run-env-override-")); + const originalPatch = process.env.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON; + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "env-override.yaml"), + [ + "id: env-override", + "title: Env Override", + "mode: agent-browser", + "area: pipeline", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: false", + "automation: scripts/write-env.mjs", + "automation_runner_config_patch_json: '{\"source\":\"default\"}'", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "write-env.mjs"), + [ + "import { writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "writeFileSync(join(process.env.LBS_ROOT, 'env-out.json'), JSON.stringify({", + " patch: process.env.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON,", + "}));", + ].join("\n"), + ); + + process.env.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON = '{"source":"explicit"}'; + const result = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "env-override", "--run-id", "env-run"], + })); + + assert.equal(result.code, 0); + const observed = JSON.parse(readFileSync(join(tmp, "env-out.json"), "utf8")); + assert.equal(observed.patch, '{"source":"explicit"}'); + } finally { + if (originalPatch === undefined) delete process.env.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON; + else process.env.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON = originalPatch; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test run expands env references in automation defaults", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-run-env-expand-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), "QA_KB_UUID=kb-from-env\nQA_MODEL_UUID=model-from-env\n"); + writeFileSync( + join(casesDir, "env-expand.yaml"), + [ + "id: env-expand", + "title: Env Expand", + "mode: agent-browser", + "area: pipeline", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: false", + "automation: scripts/write-expanded-env.mjs", + "automation_runner_config_patch_json: '{\"knowledge-bases\":[\"${QA_KB_UUID}\"],\"model\":{\"primary\":\"${QA_MODEL_UUID}\"}}'", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "write-expanded-env.mjs"), + [ + "import { writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "writeFileSync(join(process.env.LBS_ROOT, 'expanded-env-out.json'), JSON.stringify({", + " patch: process.env.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON,", + "}));", + ].join("\n"), + ); + + const dryRun = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "env-expand", "--run-id", "env-expand-dry", "--dry-run", "--json"], + })); + assert.equal(dryRun.code, 0); + const plan = JSON.parse(dryRun.output); + assert.equal( + plan.automation.env_defaults.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON, + '{"knowledge-bases":["kb-from-env"],"model":{"primary":"model-from-env"}}', + ); + + const run = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "env-expand", "--run-id", "env-expand-run"], + })); + assert.equal(run.code, 0); + const observed = JSON.parse(readFileSync(join(tmp, "expanded-env-out.json"), "utf8")); + assert.equal(observed.patch, '{"knowledge-bases":["kb-from-env"],"model":{"primary":"model-from-env"}}'); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test run setup automation isolates evidence and reloads env", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-run-setup-automation-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), "SETUP_VALUE=\n"); + writeFileSync( + join(casesDir, "setup-main.yaml"), + [ + "id: setup-main", + "title: Setup Main", + "mode: agent-browser", + "area: pipeline", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: false", + "env:", + " - SETUP_VALUE", + "setup_automation:", + " - \"node:scripts/write-setup-env.mjs --write-env\"", + "setup_provides_env:", + " - SETUP_VALUE", + "automation: scripts/read-setup-env.mjs", + ].join("\n"), + ); + writeFileSync( + join(casesDir, "setup-env-issue.yaml"), + [ + "id: setup-env-issue", + "title: Setup Env Issue", + "mode: agent-browser", + "area: pipeline", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: false", + "setup_automation:", + " - \"node:scripts/write-setup-env-issue.mjs\"", + "automation: scripts/read-setup-env.mjs", + ].join("\n"), + ); + writeFileSync( + join(casesDir, "setup-fail-after-pass.yaml"), + [ + "id: setup-fail-after-pass", + "title: Setup Fail After Pass", + "mode: agent-browser", + "area: pipeline", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: false", + "setup_automation:", + " - \"node:scripts/write-setup-pass-then-fail.mjs\"", + "automation: scripts/read-setup-env.mjs", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "write-setup-env.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { dirname, join } from 'node:path';", + "const local = join(process.env.LBS_ROOT, 'skills', '.env.local');", + "writeFileSync(local, 'SETUP_VALUE=from-setup\\n');", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ status: 'pass', stage: 'setup' }));", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify({ status: 'pass', stage: 'setup' }));", + "writeFileSync(join(dirname(process.env.LBS_EVIDENCE_DIR), 'setup-ran.txt'), 'yes');", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "read-setup-env.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_ROOT, 'main-observed.json'), JSON.stringify({ value: process.env.SETUP_VALUE }));", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ status: 'pass', stage: 'main' }));", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify({ status: 'pass', stage: 'main' }));", + "if (process.env.SETUP_VALUE !== 'from-setup') process.exit(1);", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "write-setup-env-issue.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ status: 'env_issue', reason: 'setup env missing' }));", + "process.exit(2);", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "write-setup-pass-then-fail.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ status: 'pass', reason: 'stale pass before crash' }));", + "process.exit(1);", + ].join("\n"), + ); + + const dryRun = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "setup-main", "--run-id", "setup-run", "--output", join(tmp, "evidence"), "--dry-run", "--json"], + })); + assert.equal(dryRun.code, 0); + const plan = JSON.parse(dryRun.output); + assert.equal(plan.setup_automation.length, 1); + assert.match(plan.setup_automation[0].evidence_dir, /setup\/01-write-setup-env$/); + assert.match(plan.setup_automation[0].command, /^node scripts\/write-setup-env\.mjs --write-env$/); + assert.equal(plan.setup_automation[0].dry_run_command, ""); + assert.equal(existsSync(join(tmp, "skills", ".env.local")), false); + + const run = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "setup-main", "--run-id", "setup-run", "--output", join(tmp, "evidence")], + })); + assert.equal(run.code, 0); + const observed = JSON.parse(readFileSync(join(tmp, "main-observed.json"), "utf8")); + assert.equal(observed.value, "from-setup"); + const setupResult = JSON.parse(readFileSync(join(tmp, "evidence", "setup", "01-write-setup-env", "automation-result.json"), "utf8")); + const mainResult = JSON.parse(readFileSync(join(tmp, "evidence", "automation-result.json"), "utf8")); + assert.equal(setupResult.stage, "setup"); + assert.equal(mainResult.stage, "main"); + + const envIssue = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "setup-env-issue", "--run-id", "setup-env-issue-run", "--output", join(tmp, "evidence-env-issue")], + })); + assert.equal(envIssue.code, 2); + const parentResult = JSON.parse(readFileSync(join(tmp, "evidence-env-issue", "automation-result.json"), "utf8")); + assert.equal(parentResult.status, "env_issue"); + assert.equal(parentResult.reason, "setup env missing"); + + const failAfterPass = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "setup-fail-after-pass", "--run-id", "setup-fail-after-pass-run", "--output", join(tmp, "evidence-fail-after-pass")], + })); + assert.equal(failAfterPass.code, 1); + const failAfterPassResult = JSON.parse(readFileSync(join(tmp, "evidence-fail-after-pass", "automation-result.json"), "utf8")); + assert.equal(failAfterPassResult.status, "fail"); + assert.equal(failAfterPassResult.reason, "stale pass before crash"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test run setup automation can execute another case outside this source repo", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-run-setup-case-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), "SETUP_VALUE=\n"); + writeFileSync( + join(casesDir, "setup-child.yaml"), + [ + "id: setup-child", + "title: Setup Child", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/write-child-env.mjs", + ].join("\n"), + ); + writeFileSync( + join(casesDir, "setup-parent.yaml"), + [ + "id: setup-parent", + "title: Setup Parent", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "setup_automation:", + " - \"case:setup-child\"", + "setup_provides_env:", + " - SETUP_VALUE", + "automation: scripts/read-child-env.mjs", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "write-child-env.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "writeFileSync(join(process.env.LBS_ROOT, 'skills', '.env.local'), 'SETUP_VALUE=from-child\\n');", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ status: 'pass' }));", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify({ status: 'pass' }));", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "read-child-env.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ status: 'pass', value: process.env.SETUP_VALUE }));", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify({ status: 'pass', value: process.env.SETUP_VALUE }));", + "if (process.env.SETUP_VALUE !== 'from-child') process.exit(1);", + ].join("\n"), + ); + + const run = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "setup-parent", "--run-id", "setup-parent-run", "--output", join(tmp, "evidence")], + })); + + assert.equal(run.code, 0); + assert.ok(existsSync(join(tmp, "evidence", "setup", "01-setup-child", "result.json"))); + const result = JSON.parse(readFileSync(join(tmp, "evidence", "automation-result.json"), "utf8")); + assert.equal(result.value, "from-child"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test run automation inherits parent process environment", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-run-env-inherit-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "env-inherit.yaml"), + [ + "id: env-inherit", + "title: Env Inherit", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/read-path.mjs", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "read-path.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ status: process.env.PATH ? 'pass' : 'fail' }));", + "process.exit(process.env.PATH ? 0 : 1);", + ].join("\n"), + ); + + const run = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "env-inherit", "--run-id", "env-inherit-run", "--output", join(tmp, "evidence")], + })); + + assert.equal(run.code, 0); + const result = JSON.parse(readFileSync(join(tmp, "evidence", "automation-result.json"), "utf8")); + assert.equal(result.status, "pass"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test run dry-run marks missing setup case targets", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-run-setup-missing-case-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "setup-parent.yaml"), + [ + "id: setup-parent", + "title: Setup Parent", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "setup_automation:", + " - \"case:missing-child\"", + "automation: scripts/pass.mjs", + ].join("\n"), + ); + writeFileSync(join(scriptsDir, "pass.mjs"), "process.exit(0);\n"); + + const result = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "setup-parent", "--dry-run", "--json"], + })); + + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.equal(run.setup_automation[0].entry, "case:missing-child"); + assert.doesNotMatch(run.setup_automation[0].command, /--dry-run/); + assert.match(run.setup_automation[0].dry_run_command, /--dry-run/); + assert.equal(run.setup_automation[0].exists, false); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("local-agent effective prompt case has runnable automation defaults", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "local-agent-effective-prompt-debug-chat", + "--run-id", + "effective-run", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.equal(run.automation.script, "scripts/e2e/pipeline-debug-chat.mjs"); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_PROMPT, "qa-effective-prompt"); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_TEXT, "PROMPT_PREPROCESS_OK"); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_RESPONSE_TIMEOUT_MS, "180000"); + assert.equal(run.automation.pipeline_env_required, true); + assert.ok(run.automation.env_aliases.some((alias: { target: string; source: string }) => ( + alias.target === "LANGBOT_E2E_PIPELINE_URL" && alias.source === "LANGBOT_LOCAL_AGENT_PIPELINE_URL" + ))); +}); + +test("local-agent basic case can setup the local-agent pipeline env", () => { + withEnv({ + LANGBOT_BROWSER_PROFILE: "/tmp/langbot-test-profile", + LANGBOT_CHROMIUM_EXECUTABLE: "/tmp/langbot-test-chromium", + }, () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "local-agent-basic-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.deepEqual(run.setup_automation.map((item: { entry: string }) => item.entry), [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + ]); + + const planResult = capture(() => commandTestPlan(ctx(["test", "plan", "local-agent-basic-debug-chat", "--json"]))); + assert.equal(planResult.code, 0); + const plan = JSON.parse(planResult.output); + assert.deepEqual(plan.setup_provides_env, [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + ]); + assert.equal(plan.automation_readiness.status, "ready"); + }); +}); + +test("local-agent nonstreaming case disables stream output through automation defaults", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "local-agent-nonstreaming-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.equal(run.automation.script, "scripts/e2e/pipeline-debug-chat.mjs"); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_PROMPT, "Reply only NONSTREAM_OK."); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_TEXT, "NONSTREAM_OK"); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_STREAM_OUTPUT, "0"); + assert.equal(run.automation.pipeline_env_required, true); +}); + +test("local-agent multimodal case exposes image fixture automation defaults", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "local-agent-multimodal-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.equal(run.automation.script, "scripts/e2e/pipeline-debug-chat.mjs"); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_TEXT, "IMAGE_OK"); + assert.match(run.automation.env_defaults.LANGBOT_E2E_IMAGE_BASE64_PATH, /red-square\.png\.base64$/); + assert.equal(run.automation.pipeline_env_required, true); +}); + +test("MCP stdio case passes case-specific failure signals to automation defaults", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "mcp-stdio-tool-call", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.match(run.automation.env_defaults.LANGBOT_E2E_FAILURE_SIGNALS, /qa-plugin-smoke:mcp-ok-local-agent/); + assert.match(run.automation.env_defaults.LANGBOT_E2E_FAILURE_SIGNALS, /model_not_found/); +}); + +test("MCP stdio tool-call case setups pipeline and registered MCP server", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "mcp-stdio-tool-call", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.deepEqual(run.setup_automation.map((item: { entry: string }) => item.entry), [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "case:mcp-stdio-register", + ]); + + const planResult = capture(() => commandTestPlan(ctx(["test", "plan", "mcp-stdio-tool-call", "--json"]))); + assert.equal(planResult.code, 0); + const plan = JSON.parse(planResult.output); + assert.deepEqual(plan.setup_provides_env, [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + ]); + assert.ok(!plan.preconditions.some((item: string) => item.includes("points to the local-agent pipeline"))); +}); + +test("generic pipeline automation can still use the shared pipeline env", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "pipeline-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.equal(run.automation.pipeline_env_required, false); + assert.deepEqual(run.automation.env_aliases, []); + assert.ok(run.automation.required_env.includes("LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME")); +}); + +test("AgentRunner live install case exposes package automation defaults", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "agent-runner-live-install", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_PLUGIN_PACKAGE, + "skills/langbot-testing/fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg", + ); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_PLUGIN_ID, "qa/agent-runner"); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_RUNNER_ID, "plugin:qa/agent-runner/default"); +}); + +test("QA plugin live install checks the fixture package before installed state", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-install-qa-plugin-")); + try { + const result = spawnSync( + process.execPath, + [join(root, "scripts/e2e/install-qa-plugin-smoke.mjs")], + { + cwd: root, + env: { + ...process.env, + LBS_RUN_ID: "missing-package", + LBS_EVIDENCE_DIR: join(tmp, "evidence"), + LANGBOT_BACKEND_URL: "http://127.0.0.1:59999", + LANGBOT_E2E_LOGIN_USER: "qa@example.test", + LANGBOT_E2E_PLUGIN_PACKAGE: join(tmp, "missing.lbpkg"), + }, + encoding: "utf8", + }, + ); + assert.equal(result.status, 1); + const output = JSON.parse(result.stdout); + assert.match(output.reason, /missing\.lbpkg/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("AgentRunner QA Debug Chat case uses dedicated pipeline env", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "agent-runner-qa-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.equal(run.automation.script, "scripts/e2e/pipeline-debug-chat.mjs"); + assert.equal(run.automation.pipeline_env_required, true); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_RUNNER_ID, "plugin:qa/agent-runner/default"); + assert.deepEqual( + run.setup_automation.map((item: { entry: string }) => item.entry), + [ + "case:agent-runner-live-install", + "node:scripts/e2e/ensure-qa-agent-runner-pipeline.mjs --write-env", + ], + ); + assert.ok(run.automation.env_aliases.some((alias: { target: string; source: string }) => ( + alias.target === "LANGBOT_E2E_PIPELINE_URL" && alias.source === "LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL" + ))); +}); + +test("AgentRunner QA Debug Chat setup automation removes manual readiness", () => { + withEnv({ + LANGBOT_BROWSER_PROFILE: "/tmp/langbot-test-profile", + LANGBOT_CHROMIUM_EXECUTABLE: "/tmp/langbot-test-chromium", + }, () => { + const planResult = capture(() => commandTestPlan(ctx(["test", "plan", "agent-runner-qa-debug-chat", "--json"]))); + assert.equal(planResult.code, 0); + const plan = JSON.parse(planResult.output); + assert.equal(plan.manual_readiness.status, "not_required"); + assert.deepEqual(plan.setup_provides_env, [ + "LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL", + "LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME", + ]); + assert.equal(plan.automation_readiness.status, "ready"); + + const suiteResult = capture(() => commandSuitePlan(ctx(["suite", "plan", "agent-runner-release-gate", "--json"]))); + assert.equal(suiteResult.code, 0); + const suite = JSON.parse(suiteResult.output); + assert.ok(!suite.readiness.manual_check_cases.includes("agent-runner-qa-debug-chat")); + }); +}); + +test("ACP AgentRunner Debug Chat case setups the ACP pipeline env", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "acp-agent-runner-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.deepEqual(run.setup_automation.map((item: { entry: string }) => item.entry), [ + "node:scripts/e2e/ensure-acp-agent-runner-pipeline.mjs --write-env", + ]); + assert.ok(run.automation.env_aliases.some((alias: { target: string; source: string }) => ( + alias.target === "LANGBOT_E2E_PIPELINE_URL" && alias.source === "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL" + ))); + + const planResult = capture(() => commandTestPlan(ctx(["test", "plan", "acp-agent-runner-debug-chat", "--json"]))); + assert.equal(planResult.code, 0); + const plan = JSON.parse(planResult.output); + assert.deepEqual(plan.setup_provides_env, [ + "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL", + "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME", + ]); + assert.ok(!plan.preconditions.some((item: string) => item.includes("pipeline AI runner"))); +}); + +test("local-agent plugin cases setup the QA plugin smoke fixture", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "local-agent-plugin-tool-call-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.deepEqual(run.setup_automation.map((item: { entry: string }) => item.entry), [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "case:qa-plugin-smoke-live-install", + ]); +}); + +test("local-agent RAG case only requires the KB fixture env", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "local-agent-rag-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.ok(run.automation.required_env.includes("LANGBOT_LOCAL_AGENT_RAG_KB_UUID")); + assert.ok(!run.automation.required_env.includes("LANGBOT_LOCAL_AGENT_RAG_TEXT_MODEL_UUID")); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON, + JSON.stringify({ + "knowledge-bases": [ + loadEnv(root).LANGBOT_LOCAL_AGENT_RAG_KB_UUID || "", + ], + }), + ); +}); + +test("LangRAG retrieve readiness requires a KB UUID alternative", () => { + const result = capture(() => commandTestPlan(ctx(["test", "plan", "langrag-kb-retrieve", "--json"]))); + assert.equal(result.code, 0); + const plan = JSON.parse(result.output); + assert.ok(plan.automation_readiness.required.includes("LANGBOT_LOCAL_AGENT_RAG_KB_UUID|LANGBOT_RAG_KB_UUID")); +}); + +test("local-agent RAG multimodal case setups the KB fixture env", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "local-agent-rag-multimodal-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.ok(run.automation.required_env.includes("LANGBOT_LOCAL_AGENT_RAG_KB_UUID")); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON, + JSON.stringify({ + "knowledge-bases": [ + loadEnv(root).LANGBOT_LOCAL_AGENT_RAG_KB_UUID || "", + ], + }), + ); + assert.deepEqual(run.setup_automation.map((item: { entry: string }) => item.entry), [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env", + ]); +}); + +test("test report renders a reusable evidence template", () => { + const result = capture(() => commandTestReport(ctx(["test", "report", "pipeline-debug-chat", "--no-auto-log"]))); + assert.equal(result.code, 0); + assert.match(result.output, /^# Test Report: pipeline-debug-chat/m); + assert.match(result.output, /result: pass \| fail \| blocked \| env_issue \| flaky/); + assert.match(result.output, /## Log Guard/); + assert.match(result.output, /## Automation Result/); + assert.match(result.output, /## Required Evidence/); + assert.match(result.output, /no log files provided/); +}); + +test("test report promotes loaded automation evidence into result section", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-automation-")); + try { + writeFileSync( + join(tmp, "automation-result.json"), + JSON.stringify({ + status: "pass", + reason: "latency thresholds passed", + url: "http://127.0.0.1:5300", + artifacts: { metrics_json: join(tmp, "metrics.json") }, + }), + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "langbot-live-backend-latency", + "--evidence-dir", + tmp, + "--no-auto-log", + ]))); + + assert.equal(result.code, 0); + assert.match(result.output, /## Result\n- result: pass\n- reason: latency thresholds passed/); + assert.match(result.output, /- target_tested: http:\/\/127\.0\.0\.1:5300/); + assert.doesNotMatch(result.output, /target_tested: TODO/); + assert.match(result.output, /## Automation Result/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("validate rejects dangling case references and missing automation scripts", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-validate-strict-")); + try { + const schemasDir = join(tmp, "schemas"); + const skillsDir = join(tmp, "skills"); + const envSetupDir = join(skillsDir, "langbot-env-setup"); + const testingDir = join(skillsDir, "langbot-testing"); + mkdirSync(schemasDir, { recursive: true }); + mkdirSync(join(testingDir, "cases"), { recursive: true }); + mkdirSync(join(testingDir, "fixtures"), { recursive: true }); + mkdirSync(join(testingDir, "suites"), { recursive: true }); + mkdirSync(envSetupDir, { recursive: true }); + for (const schemaName of ["case.schema.json", "suite.schema.json", "troubleshooting.schema.json", "skill-index.schema.json"]) { + writeFileSync(join(schemasDir, schemaName), "{}"); + } + writeFileSync(join(envSetupDir, "SKILL.md"), "---\nname: langbot-env-setup\ndescription: Env setup.\n---\n\n# Env\n"); + writeFileSync(join(testingDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(skillsDir, ".env"), + [ + "LANGBOT_FRONTEND_URL=http://127.0.0.1:3000", + "LANGBOT_BACKEND_URL=http://127.0.0.1:5300", + "LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:3000", + "LANGBOT_REPO=/tmp/langbot", + "LANGBOT_WEB_REPO=/tmp/langbot/web", + "LANGBOT_BROWSER_PROFILE=/tmp/browser", + "LANGBOT_CHROMIUM_EXECUTABLE=/tmp/chromium", + "LANGBOT_PROXY_HTTP=http://127.0.0.1:7890", + "LANGBOT_PROXY_SOCKS=socks5://127.0.0.1:7890", + "LANGBOT_NO_PROXY=localhost,127.0.0.1,::1", + ].join("\n"), + ); + writeFileSync( + join(testingDir, "cases", "bad.yaml"), + [ + "id: bad", + "title: Bad", + "mode: agent-browser", + "area: pipeline", + "type: smoke", + "priority: p9", + "risk: medium", + "ci_eligible: false", + "tags:", + " - smoke", + "skills:", + " - langbot-env-setup", + " - langbot-testing", + "env:", + " - LANGBOT_FRONTEND_URL", + "automation: scripts/e2e/missing.mjs", + "setup_provides_env:", + " - LANGBOT_PIPELINE_URL", + "steps:", + " - Open UI.", + "checks:", + " - UI works.", + "evidence_required:", + " - ui", + "troubleshooting:", + " - missing-trouble", + ].join("\n"), + ); + for (const [id, target] of [["cycle-a", "cycle-b"], ["cycle-b", "cycle-a"]]) { + writeFileSync( + join(testingDir, "cases", `${id}.yaml`), + [ + `id: ${id}`, + `title: ${id}`, + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "tags:", + " - smoke", + "skills:", + " - langbot-testing", + "setup_automation:", + ` - \"case:${target}\"`, + "steps:", + " - Run probe.", + "checks:", + " - Probe works.", + "evidence_required:", + " - filesystem", + ].join("\n"), + ); + } + writeFileSync( + join(testingDir, "suites", "bad-suite.yaml"), + [ + "id: bad-suite", + "title: Bad Suite", + "description: Bad suite for strict validation.", + "type: release_gate", + "priority: p1", + "tags:", + " - gate", + "cases:", + " - missing-case", + ].join("\n"), + ); + writeFileSync( + join(testingDir, "fixtures", "fixtures.json"), + JSON.stringify([{ id: "bad-fixture", title: "Bad Fixture", path: "fixtures/missing.txt", related_cases: ["missing-case"] }]), + ); + + const result = captureAll(() => commandValidate(tmp)); + + assert.equal(result.code, 1); + assert.match(result.error, /priority/); + assert.match(result.error, /missing-trouble/); + assert.match(result.error, /missing-case/); + assert.match(result.error, /bad-fixture/); + assert.match(result.error, /automation script does not exist/); + assert.match(result.error, /setup_provides_env/); + assert.match(result.error, /setup_automation case cycle detected/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report JSON scans logs and redacts secrets", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync( + logPath, + [ + "INFO request started", + "Action invoke_llm_stream call timed out", + "Traceback (most recent call last):", + "API_KEY=sk-test-secret", + ].join("\n"), + ); + + const result = capture(() => commandTestReport(ctx(["test", "report", "pipeline-debug-chat", "--backend-log", logPath, "--json"]))); + assert.equal(result.code, 0); + assert.doesNotMatch(result.output, /sk-test-secret/); + + const report = JSON.parse(result.output); + assert.equal(report.log_guard.status, "fail"); + assert.ok(report.log_guard.findings.some((finding: { kind: string }) => ( + finding.kind === "case_failure_pattern" + ))); + assert.ok(report.log_guard.findings.some((finding: { troubleshooting_id?: string }) => ( + finding.troubleshooting_id === "plugin-runtime-timeout" + ))); + assert.ok(report.log_guard.findings.some((finding: { kind: string }) => finding.kind === "python_traceback")); + + const secretFinding = report.log_guard.findings.find((finding: { kind: string }) => finding.kind === "secret_leak"); + assert.ok(secretFinding); + assert.match(secretFinding.excerpt, /\[redacted\]/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report does not treat invalid api key wording as a secret leak", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-api-key-wording-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync( + logPath, + "RequesterError: 模型请求失败: 无效的 api-key: Error code: 401 - invalid api key\n", + ); + + const result = capture(() => commandTestReport(ctx(["test", "report", "mcp-stdio-tool-call", "--backend-log", logPath, "--json"]))); + assert.equal(result.code, 0); + assert.match(result.output, /api-key: Error code/); + + const report = JSON.parse(result.output); + assert.ok(!report.log_guard.findings.some((finding: { kind: string }) => finding.kind === "secret_leak")); + assert.ok(report.log_guard.findings.some((finding: { troubleshooting_id?: string }) => ( + finding.troubleshooting_id === "local-agent-model-route-unavailable" + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report records declared success signals from logs", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-success-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync( + logPath, + [ + "[05-21 10:31:00.000] websocket.py (1) - [INFO] : Processing request from person_websocket", + "[05-21 10:31:01.000] runner.py (2) - [INFO] : Conversation(0) Streaming completed", + ].join("\n"), + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "pipeline-debug-chat", + "--backend-log", + logPath, + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.log_guard.status, "pass"); + assert.equal(report.log_guard.success_signals.length, 2); + assert.ok(report.log_guard.success_signals.some((signal: { pattern: string }) => ( + signal.pattern === "Streaming completed" + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report warns when declared success signals are missing", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-missing-success-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync(logPath, "INFO request started\nINFO request ended\n"); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "pipeline-debug-chat", + "--backend-log", + logPath, + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.log_guard.status, "warning"); + assert.ok(report.log_guard.findings.some((finding: { kind: string }) => ( + finding.kind === "missing_success_signal" + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report can limit log guard to tail lines", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-tail-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync( + logPath, + [ + "ERROR old failure outside scan window", + "INFO middle", + "Action invoke_llm_stream call timed out", + "API_KEY=sk-tail-secret", + ].join("\n"), + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "pipeline-debug-chat", + "--backend-log", + logPath, + "--tail-lines", + "2", + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.log_guard.scan.mode, "tail-lines"); + assert.equal(report.log_guard.scan.tail_lines, 2); + assert.equal(report.log_guard.sources[0].line_count, 2); + assert.equal(report.log_guard.sources[0].start_line, 3); + assert.ok(report.log_guard.findings.some((finding: { troubleshooting_id?: string }) => ( + finding.troubleshooting_id === "plugin-runtime-timeout" + ))); + assert.ok(!report.log_guard.findings.some((finding: { kind: string; excerpt?: string }) => ( + finding.kind === "error_log" && finding.excerpt?.includes("old failure") + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report can limit log guard with since timestamp", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-since-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync( + logPath, + [ + "[05-21 09:59:00.000] old.py (1) - [ERROR] : old failure outside scan window", + "[05-21 10:31:00.000] runner.py (2) - [ERROR] : Action invoke_llm_stream call timed out", + "Traceback continuation should stay with the matching timestamp block", + "[05-21 10:32:00.000] secrets.py (3) - [INFO] : API_KEY=sk-since-secret", + ].join("\n"), + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "pipeline-debug-chat", + "--backend-log", + logPath, + "--since", + "2026-05-21T10:30:00+08:00", + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.log_guard.scan.mode, "since"); + assert.equal(report.log_guard.sources[0].line_count, 3); + assert.equal(report.log_guard.sources[0].start_line, 2); + assert.equal(report.log_guard.sources[0].timestamped_line_count, 3); + assert.ok(report.log_guard.findings.some((finding: { line?: number; troubleshooting_id?: string }) => ( + finding.line === 2 && finding.troubleshooting_id === "plugin-runtime-timeout" + ))); + assert.ok(!report.log_guard.findings.some((finding: { excerpt?: string }) => finding.excerpt?.includes("old failure"))); + assert.doesNotMatch(result.output, /sk-since-secret/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report can limit log guard with since and until timestamps", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-window-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync( + logPath, + [ + "[05-21 10:29:59.000] old.py (1) - [ERROR] : old failure outside scan window", + "[05-21 10:31:00.000] runner.py (2) - [INFO] : Processing request from person_websocket", + "[05-21 10:31:01.000] runner.py (3) - [INFO] : Conversation(0) Streaming completed", + "[05-21 10:32:01.000] later.py (4) - [ERROR] : later failure outside scan window", + ].join("\n"), + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "pipeline-debug-chat", + "--backend-log", + logPath, + "--since", + "2026-05-21T10:30:00+08:00", + "--until", + "2026-05-21T10:32:00+08:00", + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.log_guard.scan.mode, "since+until"); + assert.equal(report.log_guard.sources[0].line_count, 2); + assert.equal(report.log_guard.sources[0].start_line, 2); + assert.equal(report.log_guard.sources[0].end_line, 3); + assert.equal(report.log_guard.status, "pass"); + assert.ok(!report.log_guard.findings.some((finding: { excerpt?: string }) => finding.excerpt?.includes("old failure"))); + assert.ok(!report.log_guard.findings.some((finding: { excerpt?: string }) => finding.excerpt?.includes("later failure"))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report classifies model route failures as env_issue", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-env-issue-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync( + logPath, + "[05-21 10:31:00.000] runner.py (2) - [ERROR] : runner.llm_error model_not_found no available channel for model gpt-test\n", + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "local-agent-plugin-tool-call-debug-chat", + "--backend-log", + logPath, + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.log_guard.status, "env_issue"); + assert.ok(report.log_guard.findings.some((finding: { severity?: string; troubleshooting_id?: string }) => ( + finding.severity === "env_issue" && finding.troubleshooting_id === "local-agent-model-route-unavailable" + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report infers scan window from automation result evidence", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-evidence-window-")); + try { + const evidenceDir = join(tmp, "evidence", "run-123"); + mkdirSync(evidenceDir, { recursive: true }); + const consoleLog = join(evidenceDir, "console.log"); + writeFileSync( + consoleLog, + [ + "[05-21 10:29:59.000] old.js (1) - [ERROR] : old failure outside scan window", + "[05-21 10:31:00.000] runner.js (2) - [INFO] : Processing request from person_websocket", + "[05-21 10:31:01.000] runner.js (3) - [INFO] : Conversation(0) Streaming completed", + "[05-21 10:32:01.000] later.js (4) - [ERROR] : later failure outside scan window", + ].join("\n"), + ); + writeFileSync( + join(evidenceDir, "automation-result.json"), + JSON.stringify({ + source: "automation", + status: "pass", + reason: "UI sentinel appeared.", + started_at_local: "2026-05-21T10:30:00.000+08:00", + finished_at_local: "2026-05-21T10:32:00.000+08:00", + }), + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "pipeline-debug-chat", + "--console-log", + consoleLog, + "--no-auto-log", + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.log_guard.scan.mode, "since+until"); + assert.equal(report.log_guard.scan.since, "2026-05-21T10:30:00.000+08:00"); + assert.equal(report.log_guard.scan.until, "2026-05-21T10:32:00.000+08:00"); + assert.equal(report.log_guard.sources[0].line_count, 2); + assert.equal(report.log_guard.status, "pass"); + assert.equal(report.automation_result.status, "loaded"); + assert.equal(report.automation_result.result, "pass"); + assert.equal(report.automation_result.reason, "UI sentinel appeared."); + assert.ok(!report.log_guard.findings.some((finding: { excerpt?: string }) => finding.excerpt?.includes("old failure"))); + assert.ok(!report.log_guard.findings.some((finding: { excerpt?: string }) => finding.excerpt?.includes("later failure"))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report does not treat final result as automation evidence", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-final-result-")); + try { + const evidenceDir = join(tmp, "evidence", "run-final"); + mkdirSync(evidenceDir, { recursive: true }); + const consoleLog = join(evidenceDir, "console.log"); + writeFileSync(consoleLog, "[05-21 10:31:00.000] ui.js (1) - [INFO] : opened\n"); + writeFileSync( + join(evidenceDir, "result.json"), + JSON.stringify({ + source: "final", + status: "pass", + reason: "Final manual decision.", + started_at_local: "2026-05-21T10:30:00.000+08:00", + finished_at_local: "2026-05-21T10:32:00.000+08:00", + evidence_collected: ["ui", "screenshot", "console"], + }), + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "webui-login-state", + "--console-log", + consoleLog, + "--no-auto-log", + "--json", + ]))); + + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.automation_result.status, "not_provided"); + assert.match(report.automation_result.reason, /only final result\.json is present/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report still scans untimestamped explicit console evidence within an inferred run window", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-untimestamped-console-")); + try { + const evidenceDir = join(tmp, "evidence", "run-untimestamped"); + mkdirSync(evidenceDir, { recursive: true }); + const consoleLog = join(evidenceDir, "console.log"); + writeFileSync(consoleLog, "[error] Uncaught TypeError: Cannot read properties of undefined\n"); + writeFileSync( + join(evidenceDir, "result.json"), + JSON.stringify({ + status: "pass", + reason: "UI sentinel appeared.", + started_at_local: "2026-05-21T10:30:00.000+08:00", + finished_at_local: "2026-05-21T10:32:00.000+08:00", + }), + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "webui-login-state", + "--console-log", + consoleLog, + "--no-auto-log", + "--json", + ]))); + + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.log_guard.scan.mode, "since+until"); + assert.equal(report.log_guard.sources[0].timestamped_line_count, 0); + assert.ok(report.log_guard.sources[0].line_count >= 1); + assert.equal(report.log_guard.status, "fail"); + assert.ok(report.log_guard.findings.some((finding: { kind: string }) => ( + finding.kind === "frontend_uncaught_error" + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report can write markdown to an output path", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-output-")); + try { + const output = join(tmp, "reports", "pipeline-debug-chat.md"); + const result = capture(() => commandTestReport(ctx(["test", "report", "pipeline-debug-chat", "--output", output]))); + assert.equal(result.code, 0); + assert.match(result.output, /pipeline-debug-chat\.md$/); + assert.match(readFileSync(output, "utf8"), /^# Test Report: pipeline-debug-chat/m); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("log scan reuses case-aware log guard patterns", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-log-scan-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync( + logPath, + [ + "[05-21 10:31:00.000] process.py (1) - [INFO] : Processing request from person_websocket", + "[05-21 10:31:01.000] chat.py (2) - [INFO] : Conversation(0) Streaming completed", + "[05-21 10:31:02.000] runner.py (3) - [ERROR] : Action invoke_llm_stream call timed out", + ].join("\n"), + ); + + const result = capture(() => commandLogScan(ctx([ + "log", + "scan", + "--backend-log", + logPath, + "--case", + "pipeline-debug-chat", + "--json", + ]))); + + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.status, "fail"); + assert.ok(report.success_signals.some((signal: { pattern: string }) => signal.pattern === "Streaming completed")); + assert.ok(report.findings.some((finding: { kind: string }) => finding.kind === "case_failure_pattern")); + + const strict = capture(() => commandLogScan(ctx([ + "log", + "scan", + "--backend-log", + logPath, + "--case", + "pipeline-debug-chat", + "--strict", + "--json", + ]))); + assert.equal(strict.code, 1); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("log guard start and stop bound a QA log window", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-log-guard-")); + try { + const logPath = join(tmp, "backend.log"); + const outputDir = join(tmp, "guards"); + writeFileSync(logPath, "INFO before guard\n"); + + const start = capture(() => commandLogGuard(ctx([ + "log", + "guard", + "start", + "--run-id", + "qa-run", + "--output-dir", + outputDir, + "--backend-log", + logPath, + "--case", + "pipeline-debug-chat", + "--json", + ]))); + assert.equal(start.code, 0); + const session = JSON.parse(start.output); + assert.equal(session.run_id, "qa-run"); + assert.ok(existsSync(join(outputDir, "qa-run.json"))); + + appendFileSync(logPath, "Traceback (most recent call last):\n"); + const stop = capture(() => commandLogGuard(ctx([ + "log", + "guard", + "stop", + "--run-id", + "qa-run", + "--output-dir", + outputDir, + "--json", + ]))); + + assert.equal(stop.code, 1); + const report = JSON.parse(stop.output); + assert.equal(report.session.run_id, "qa-run"); + assert.equal(report.result.status, "fail"); + assert.ok(report.result.findings.some((finding: { kind: string }) => finding.kind === "python_traceback")); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("log watch observes appended LangBot backend lines", async () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-log-watch-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync(logPath, "INFO existing line\n"); + + const watching = captureAsync(() => commandLogWatch(ctx([ + "log", + "watch", + "--backend-log", + logPath, + "--duration-ms", + "220", + "--interval-ms", + "20", + "--strict", + "--json", + ]))); + setTimeout(() => { + appendFileSync(logPath, "Traceback (most recent call last):\n"); + }, 50); + + const result = await watching; + assert.equal(result.code, 1); + const summary = JSON.parse(result.output); + assert.equal(summary.mode, "watch"); + assert.equal(summary.status, "fail"); + assert.ok(summary.bytes_read > 0); + assert.ok(summary.findings.some((finding: { kind: string }) => finding.kind === "python_traceback")); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("trouble search finds structured troubleshooting entries", () => { + const result = capture(() => commandTroubleSearch(ctx(["trouble", "search", "proxy"]))); + assert.equal(result.code, 0); + assert.match(result.output, /proxy-env-mismatch/); +}); + +test("env local overrides shared env defaults", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-env-")); + try { + mkdirSync(join(tmp, "skills")); + writeFileSync(join(tmp, "skills", ".env"), "LANGBOT_REPO=/shared\nLANGBOT_BACKEND_URL=http://127.0.0.1:5300\n"); + writeFileSync(join(tmp, "skills", ".env.local"), "LANGBOT_REPO=/local\n"); + + assert.deepEqual(loadEnv(tmp), { + LANGBOT_REPO: "/local", + LANGBOT_BACKEND_URL: "http://127.0.0.1:5300", + }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/src/langbot/__init__.py b/src/langbot/__init__.py index 9fa15e197..272d8db55 100644 --- a/src/langbot/__init__.py +++ b/src/langbot/__init__.py @@ -1,3 +1,5 @@ """LangBot - Production-grade platform for building agentic IM bots""" -__version__ = '4.9.7' +from importlib.metadata import version + +__version__ = version('langbot') diff --git a/src/langbot/__main__.py b/src/langbot/__main__.py index b94500e75..485598296 100644 --- a/src/langbot/__main__.py +++ b/src/langbot/__main__.py @@ -5,6 +5,8 @@ import argparse import sys import os +from langbot.pkg.utils import paths + # ASCII art banner asciiart = r""" _ ___ _ @@ -27,6 +29,12 @@ async def main_entry(loop: asyncio.AbstractEventLoop): help='Use standalone plugin runtime / 使用独立插件运行时', default=False, ) + parser.add_argument( + '--standalone-box', + action='store_true', + help='Use standalone box runtime / 使用独立 Box 运行时', + default=False, + ) parser.add_argument('--debug', action='store_true', help='Debug mode / 调试模式', default=False) args = parser.parse_args() @@ -35,6 +43,11 @@ async def main_entry(loop: asyncio.AbstractEventLoop): platform.standalone_runtime = True + if args.standalone_box: + from langbot.pkg.utils import platform + + platform.standalone_box = True + if args.debug: from langbot.pkg.utils import constants @@ -87,7 +100,7 @@ def main(): # Set up the working directory # When installed as a package, we need to handle the working directory differently # We'll create data directory in current working directory if not exists - os.makedirs('data', exist_ok=True) + os.makedirs(paths.get_data_root(), exist_ok=True) loop = asyncio.new_event_loop() diff --git a/src/langbot/libs/deerflow_api/__init__.py b/src/langbot/libs/deerflow_api/__init__.py new file mode 100644 index 000000000..160778b1d --- /dev/null +++ b/src/langbot/libs/deerflow_api/__init__.py @@ -0,0 +1,5 @@ +from .client import AsyncDeerFlowClient +from .errors import DeerFlowAPIError +from . import stream_utils + +__all__ = ['AsyncDeerFlowClient', 'DeerFlowAPIError', 'stream_utils'] diff --git a/src/langbot/libs/deerflow_api/client.py b/src/langbot/libs/deerflow_api/client.py new file mode 100644 index 000000000..b66bf7e2e --- /dev/null +++ b/src/langbot/libs/deerflow_api/client.py @@ -0,0 +1,204 @@ +"""DeerFlow LangGraph HTTP API 客户端 + +参考 astrbot 的 deerflow_api_client 实现,使用 httpx 适配 LangBot 风格。 +""" + +from __future__ import annotations + +import codecs +import json +import typing +from collections.abc import AsyncGenerator + +import httpx + +from .errors import DeerFlowAPIError + + +SSE_MAX_BUFFER_CHARS = 1_048_576 + + +def _normalize_sse_newlines(text: str) -> str: + """规范化 CRLF/CR 为 LF,确保 SSE 块分割稳定""" + return text.replace('\r\n', '\n').replace('\r', '\n') + + +def _parse_sse_data_lines(data_lines: list[str]) -> typing.Any: + raw_data = '\n'.join(data_lines) + try: + return json.loads(raw_data) + except json.JSONDecodeError: + # 某些 LangGraph 兼容服务端会在单个 SSE 事件中用多个 data 行 + # 发送多段 JSON 片段(例如 tuple payload) + parsed_lines: list[typing.Any] = [] + can_parse_all = True + for line in data_lines: + line = line.strip() + if not line: + continue + try: + parsed_lines.append(json.loads(line)) + except json.JSONDecodeError: + can_parse_all = False + break + if can_parse_all and parsed_lines: + return parsed_lines[0] if len(parsed_lines) == 1 else parsed_lines + return raw_data + + +def _parse_sse_block(block: str) -> dict[str, typing.Any] | None: + if not block.strip(): + return None + + event_name = 'message' + data_lines: list[str] = [] + for line in block.splitlines(): + if line.startswith('event:'): + event_name = line[6:].strip() + elif line.startswith('data:'): + data_lines.append(line[5:].lstrip()) + + if not data_lines: + return None + return {'event': event_name, 'data': _parse_sse_data_lines(data_lines)} + + +class AsyncDeerFlowClient: + """DeerFlow LangGraph HTTP API 客户端""" + + api_base: str + headers: dict[str, str] + + def __init__( + self, + api_base: str = 'http://127.0.0.1:2026', + api_key: str = '', + auth_header: str = '', + ) -> None: + self.api_base = api_base.rstrip('/') + self.headers: dict[str, str] = {} + if auth_header: + self.headers['Authorization'] = auth_header + elif api_key: + self.headers['Authorization'] = f'Bearer {api_key}' + + async def create_thread(self, timeout: float = 20) -> dict[str, typing.Any]: + """创建一个新的 LangGraph thread + + Returns: + 包含 thread_id 等信息的字典 + """ + url = f'{self.api_base}/api/langgraph/threads' + payload = {'metadata': {}} + + async with httpx.AsyncClient( + trust_env=True, + timeout=timeout, + ) as client: + response = await client.post( + url, + headers=self.headers, + json=payload, + ) + if response.status_code not in (200, 201): + raise DeerFlowAPIError( + operation='create thread', + status=response.status_code, + body=response.text, + url=url, + ) + return response.json() + + async def delete_thread(self, thread_id: str, timeout: float = 20) -> None: + """删除指定 thread""" + url = f'{self.api_base}/api/threads/{thread_id}' + + async with httpx.AsyncClient( + trust_env=True, + timeout=timeout, + ) as client: + response = await client.delete(url, headers=self.headers) + if response.status_code not in (200, 202, 204, 404): + raise DeerFlowAPIError( + operation='delete thread', + status=response.status_code, + body=response.text, + url=url, + thread_id=thread_id, + ) + + async def stream_run( + self, + thread_id: str, + payload: dict[str, typing.Any], + timeout: float = 120, + ) -> AsyncGenerator[dict[str, typing.Any], None]: + """运行一次 LangGraph stream 请求,逐事件 yield + + Yields: + 事件字典 {'event': event_name, 'data': parsed_data} + """ + url = f'{self.api_base}/api/langgraph/threads/{thread_id}/runs/stream' + + # 流式请求使用单独的 read timeout 控制 + stream_timeout = httpx.Timeout( + connect=min(timeout, 30), + read=timeout, + write=timeout, + pool=timeout, + ) + + async with httpx.AsyncClient( + trust_env=True, + timeout=stream_timeout, + ) as client: + async with client.stream( + 'POST', + url, + headers={ + **self.headers, + 'Accept': 'text/event-stream', + 'Content-Type': 'application/json', + }, + json=payload, + ) as resp: + if resp.status_code != 200: + body = await resp.aread() + raise DeerFlowAPIError( + operation='runs/stream request', + status=resp.status_code, + body=body.decode('utf-8', errors='replace'), + url=url, + thread_id=thread_id, + ) + + decoder = codecs.getincrementaldecoder('utf-8')('replace') + buffer = '' + + async for chunk in resp.aiter_bytes(8192): + buffer += _normalize_sse_newlines(decoder.decode(chunk)) + + while '\n\n' in buffer: + block, buffer = buffer.split('\n\n', 1) + parsed = _parse_sse_block(block) + if parsed is not None: + yield parsed + + if len(buffer) > SSE_MAX_BUFFER_CHARS: + # 缓冲区过大,强制 flush + parsed = _parse_sse_block(buffer) + if parsed is not None: + yield parsed + buffer = '' + + # flush 剩余内容 + buffer += _normalize_sse_newlines(decoder.decode(b'', final=True)) + while '\n\n' in buffer: + block, buffer = buffer.split('\n\n', 1) + parsed = _parse_sse_block(block) + if parsed is not None: + yield parsed + if buffer.strip(): + parsed = _parse_sse_block(buffer) + if parsed is not None: + yield parsed diff --git a/src/langbot/libs/deerflow_api/errors.py b/src/langbot/libs/deerflow_api/errors.py new file mode 100644 index 000000000..a3a6c0ab8 --- /dev/null +++ b/src/langbot/libs/deerflow_api/errors.py @@ -0,0 +1,30 @@ +from __future__ import annotations + + +class DeerFlowAPIError(Exception): + """DeerFlow API 请求失败""" + + def __init__( + self, + *, + operation: str = '', + status: int = 0, + body: str = '', + url: str = '', + thread_id: str | None = None, + message: str = '', + ) -> None: + self.operation = operation + self.status = status + self.body = body + self.url = url + self.thread_id = thread_id + + if message: + super().__init__(message) + return + + msg = f'DeerFlow {operation} failed: status={status}, url={url}, body={body}' + if thread_id is not None: + msg = f'DeerFlow {operation} failed: thread_id={thread_id}, status={status}, url={url}, body={body}' + super().__init__(msg) diff --git a/src/langbot/libs/deerflow_api/stream_utils.py b/src/langbot/libs/deerflow_api/stream_utils.py new file mode 100644 index 000000000..702cb14a5 --- /dev/null +++ b/src/langbot/libs/deerflow_api/stream_utils.py @@ -0,0 +1,212 @@ +"""DeerFlow LangGraph 流式响应解析工具 + +参考 astrbot 实现的 deerflow_stream_utils。 +""" + +from __future__ import annotations + +import typing +from collections.abc import Iterable + + +def extract_text(content: typing.Any) -> str: + """从消息 content 中提取纯文本""" + if isinstance(content, str): + return content + if isinstance(content, dict): + if isinstance(content.get('text'), str): + return content['text'] + if 'content' in content: + return extract_text(content.get('content')) + if 'kwargs' in content and isinstance(content['kwargs'], dict): + return extract_text(content['kwargs'].get('content')) + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + item_type = item.get('type') + if item_type == 'text' and isinstance(item.get('text'), str): + parts.append(item['text']) + elif 'content' in item: + parts.append(extract_text(item['content'])) + return '\n'.join([p for p in parts if p]).strip() + return str(content) if content is not None else '' + + +def extract_messages_from_values_data(data: typing.Any) -> list[typing.Any]: + """从 values 事件中提取 messages 列表""" + candidates: list[typing.Any] = [] + if isinstance(data, dict): + candidates.append(data) + if isinstance(data.get('values'), dict): + candidates.append(data['values']) + elif isinstance(data, list): + candidates.extend([x for x in data if isinstance(x, dict)]) + + for item in candidates: + messages = item.get('messages') + if isinstance(messages, list): + return messages + return [] + + +def is_ai_message(message: dict[str, typing.Any]) -> bool: + """判断是否为 AI/assistant 消息""" + role = str(message.get('role', '')).lower() + if role in {'assistant', 'ai'}: + return True + + msg_type = str(message.get('type', '')).lower() + if msg_type in {'ai', 'assistant', 'aimessage', 'aimessagechunk'}: + return True + if 'ai' in msg_type and all(token not in msg_type for token in ('human', 'tool', 'system')): + return True + return False + + +def extract_latest_ai_text(messages: Iterable[typing.Any]) -> str: + """获取最近一条 AI 消息的文本内容""" + if isinstance(messages, (list, tuple)): + iterable = reversed(messages) + else: + iterable = reversed(list(messages)) + + for msg in iterable: + if not isinstance(msg, dict): + continue + if is_ai_message(msg): + text = extract_text(msg.get('content')) + if text: + return text + return '' + + +def extract_latest_ai_message(messages: Iterable[typing.Any]) -> dict[str, typing.Any] | None: + """获取最近一条 AI 消息对象""" + if isinstance(messages, (list, tuple)): + iterable = reversed(messages) + else: + iterable = reversed(list(messages)) + + for msg in iterable: + if not isinstance(msg, dict): + continue + if is_ai_message(msg): + return msg + return None + + +def is_clarification_tool_message(message: dict[str, typing.Any]) -> bool: + """判断是否为澄清问题工具消息""" + msg_type = str(message.get('type', '')).lower() + tool_name = str(message.get('name', '')).lower() + return msg_type == 'tool' and tool_name == 'ask_clarification' + + +def extract_latest_clarification_text(messages: Iterable[typing.Any]) -> str: + """提取最近的澄清问题文本""" + if isinstance(messages, (list, tuple)): + iterable = reversed(messages) + else: + iterable = reversed(list(messages)) + + for msg in iterable: + if not isinstance(msg, dict): + continue + if is_clarification_tool_message(msg): + text = extract_text(msg.get('content')) + if text: + return text + return '' + + +def get_message_id(message: typing.Any) -> str: + """提取消息 ID""" + if not isinstance(message, dict): + return '' + msg_id = message.get('id') + return msg_id if isinstance(msg_id, str) else '' + + +def extract_event_message_obj(data: typing.Any) -> dict[str, typing.Any] | None: + """从事件 data 中提取消息对象""" + msg_obj = data + if isinstance(data, (list, tuple)) and data: + msg_obj = data[0] + if isinstance(msg_obj, dict) and isinstance(msg_obj.get('data'), dict): + msg_obj = msg_obj['data'] + return msg_obj if isinstance(msg_obj, dict) else None + + +def extract_ai_delta_from_event_data(data: typing.Any) -> str: + """从 messages-tuple 事件中提取 AI delta 文本""" + msg_obj = extract_event_message_obj(data) + if not msg_obj: + return '' + if is_ai_message(msg_obj): + return extract_text(msg_obj.get('content')) + return '' + + +def extract_clarification_from_event_data(data: typing.Any) -> str: + """从事件中提取澄清问题""" + msg_obj = extract_event_message_obj(data) + if not msg_obj: + return '' + if is_clarification_tool_message(msg_obj): + return extract_text(msg_obj.get('content')) + return '' + + +def _iter_custom_event_items(data: typing.Any) -> list[dict[str, typing.Any]]: + items: list[dict[str, typing.Any]] = [] + if isinstance(data, dict): + return [data] + if isinstance(data, list): + for item in data: + if isinstance(item, dict): + items.append(item) + elif isinstance(item, (list, tuple)): + for nested in item: + if isinstance(nested, dict): + items.append(nested) + return items + + +def extract_task_failures_from_custom_event(data: typing.Any) -> list[str]: + """从 custom 事件中提取子任务失败信息""" + failures: list[str] = [] + for item in _iter_custom_event_items(data): + event_type = str(item.get('type', '')).lower() + if event_type not in {'task_failed', 'task_timed_out'}: + continue + + task_id = str(item.get('task_id', '')).strip() + error_text = extract_text(item.get('error')).strip() + if task_id and error_text: + failures.append(f'{task_id}: {error_text}') + elif error_text: + failures.append(error_text) + elif task_id: + failures.append(f'{task_id}: unknown error') + else: + failures.append('unknown task failure') + return failures + + +def build_task_failure_summary(failures: list[str]) -> str: + """构建任务失败摘要""" + if not failures: + return '' + deduped: list[str] = [] + seen: set[str] = set() + for failure in failures: + if failure not in seen: + seen.add(failure) + deduped.append(failure) + if len(deduped) == 1: + return f'DeerFlow subtask failed: {deduped[0]}' + joined = '\n'.join([f'- {item}' for item in deduped[:5]]) + return f'DeerFlow subtasks failed:\n{joined}' diff --git a/src/langbot/libs/dify_service_api/v1/client.py b/src/langbot/libs/dify_service_api/v1/client.py index 45a52415a..a00eedbc1 100644 --- a/src/langbot/libs/dify_service_api/v1/client.py +++ b/src/langbot/libs/dify_service_api/v1/client.py @@ -201,7 +201,7 @@ class AsyncDifyServiceClient: 'file': file, }, data={ - 'user': (None, user), + 'user': user, }, ) diff --git a/src/langbot/libs/weknora_api/__init__.py b/src/langbot/libs/weknora_api/__init__.py new file mode 100644 index 000000000..23926101d --- /dev/null +++ b/src/langbot/libs/weknora_api/__init__.py @@ -0,0 +1,4 @@ +from .client import AsyncWeKnoraClient +from .errors import WeKnoraAPIError + +__all__ = ['AsyncWeKnoraClient', 'WeKnoraAPIError'] diff --git a/src/langbot/libs/weknora_api/client.py b/src/langbot/libs/weknora_api/client.py new file mode 100644 index 000000000..f753136d8 --- /dev/null +++ b/src/langbot/libs/weknora_api/client.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import httpx +import typing +import json + +from .errors import WeKnoraAPIError + + +class AsyncWeKnoraClient: + """WeKnora API 客户端""" + + api_key: str + base_url: str + + def __init__( + self, + api_key: str, + base_url: str = 'http://localhost:80/api/v1', + ) -> None: + self.api_key = api_key + self.base_url = base_url + + async def create_session( + self, + title: str = '', + description: str = '', + timeout: float = 30.0, + ) -> str: + """创建会话,返回 session_id""" + async with httpx.AsyncClient( + base_url=self.base_url, + trust_env=True, + timeout=timeout, + ) as client: + payload: dict[str, typing.Any] = {} + if title: + payload['title'] = title + if description: + payload['description'] = description + + response = await client.post( + '/sessions', + headers={ + 'X-API-Key': self.api_key, + 'Content-Type': 'application/json', + }, + json=payload, + ) + + if response.status_code not in (200, 201): + raise WeKnoraAPIError(f'{response.status_code} {response.text}') + + data = response.json() + return data['data']['id'] + + async def agent_chat( + self, + session_id: str, + query: str, + user: str, + agent_id: str = '', + knowledge_base_ids: list[str] | None = None, + web_search_enabled: bool = False, + timeout: float = 120.0, + ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: + """ + Agent 智能对话(SSE 流式) + + 响应事件类型: + - agent_query: Agent 开始处理 + - thinking: 思考过程 + - tool_call: 工具调用 + - tool_result: 工具结果 + - references: 知识库引用 + - answer: 回答内容 + - reflection: 反思 + - session_title: 会话标题 + - error: 错误 + """ + if knowledge_base_ids is None: + knowledge_base_ids = [] + + async with httpx.AsyncClient( + base_url=self.base_url, + trust_env=True, + timeout=timeout, + ) as client: + payload: dict[str, typing.Any] = { + 'query': query, + 'agent_enabled': True, + 'channel': 'im', + } + if agent_id: + payload['agent_id'] = agent_id + if knowledge_base_ids: + payload['knowledge_base_ids'] = knowledge_base_ids + if web_search_enabled: + payload['web_search_enabled'] = True + + async with client.stream( + 'POST', + f'/agent-chat/{session_id}', + headers={ + 'X-API-Key': self.api_key, + 'Content-Type': 'application/json', + }, + json=payload, + ) as r: + async for chunk in r.aiter_lines(): + if r.status_code != 200: + raise WeKnoraAPIError(f'{r.status_code} {chunk}') + if chunk.strip() == '': + continue + if chunk.startswith('data:'): + try: + data = json.loads(chunk[5:].strip()) + except json.JSONDecodeError: + continue + yield data + # 收到 error 事件后主动结束流,避免上层未 raise 时持续等待 + if data.get('response_type') == 'error': + return + + async def knowledge_chat( + self, + session_id: str, + query: str, + user: str, + agent_id: str = 'builtin-quick-answer', + knowledge_base_ids: list[str] | None = None, + timeout: float = 120.0, + ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: + """ + 知识库 RAG 问答(SSE 流式) + + 响应事件类型: + - references: 知识库引用 + - answer: 回答内容 + """ + if knowledge_base_ids is None: + knowledge_base_ids = [] + + async with httpx.AsyncClient( + base_url=self.base_url, + trust_env=True, + timeout=timeout, + ) as client: + payload: dict[str, typing.Any] = { + 'query': query, + 'channel': 'im', + } + if agent_id: + payload['agent_id'] = agent_id + if knowledge_base_ids: + payload['knowledge_base_ids'] = knowledge_base_ids + + async with client.stream( + 'POST', + f'/knowledge-chat/{session_id}', + headers={ + 'X-API-Key': self.api_key, + 'Content-Type': 'application/json', + }, + json=payload, + ) as r: + async for chunk in r.aiter_lines(): + if r.status_code != 200: + raise WeKnoraAPIError(f'{r.status_code} {chunk}') + if chunk.strip() == '': + continue + if chunk.startswith('data:'): + try: + data = json.loads(chunk[5:].strip()) + except json.JSONDecodeError: + continue + yield data + # 收到 error 事件后主动结束流,避免上层未 raise 时持续等待 + if data.get('response_type') == 'error': + return diff --git a/src/langbot/libs/weknora_api/errors.py b/src/langbot/libs/weknora_api/errors.py new file mode 100644 index 000000000..c43b724c4 --- /dev/null +++ b/src/langbot/libs/weknora_api/errors.py @@ -0,0 +1,6 @@ +class WeKnoraAPIError(Exception): + """WeKnora API 请求失败""" + + def __init__(self, message: str = ''): + self.message = message + super().__init__(self.message) diff --git a/src/langbot/pkg/api/http/controller/groups/box.py b/src/langbot/pkg/api/http/controller/groups/box.py new file mode 100644 index 000000000..d8c961e7a --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/box.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from langbot.pkg.utils import constants + +from .. import group +from .box_visibility import should_hide_box_runtime_status + + +@group.group_class('box', '/api/v1/box') +class BoxRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + status = await self.ap.box_service.get_status() + status['hidden'] = should_hide_box_runtime_status(constants.edition, status.get('enabled')) + return self.success(data=status) + + @self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + sessions = await self.ap.box_service.get_sessions() + return self.success(data=sessions) + + @self.route('/errors', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + errors = self.ap.box_service.get_recent_errors() + return self.success(data=errors) diff --git a/src/langbot/pkg/api/http/controller/groups/box_visibility.py b/src/langbot/pkg/api/http/controller/groups/box_visibility.py new file mode 100644 index 000000000..667203026 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/box_visibility.py @@ -0,0 +1,5 @@ +from __future__ import annotations + + +def should_hide_box_runtime_status(edition: str, box_enabled: bool | None) -> bool: + return edition == 'cloud' and box_enabled is False diff --git a/src/langbot/pkg/api/http/controller/groups/extensions.py b/src/langbot/pkg/api/http/controller/groups/extensions.py new file mode 100644 index 000000000..ac8463c90 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/extensions.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import asyncio +import quart + +from .. import group + + +@group.group_class('extensions', '/api/v1/extensions') +class ExtensionsRouterGroup(group.RouterGroup): + """Unified API for installed extensions (plugins, MCP servers, skills).""" + + async def initialize(self) -> None: + @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> quart.Response: + plugins, mcp_servers, skills = await asyncio.gather( + self.ap.plugin_connector.list_plugins(), + self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True), + self.ap.skill_service.list_skills(), + return_exceptions=True, + ) + + def _sort_key(item: dict) -> str: + if item['type'] == 'plugin': + return ( + item['plugin'] + .get('manifest', {}) + .get('manifest', {}) + .get('metadata', {}) + .get('name', '') + .lower() + ) + if item['type'] == 'mcp': + return (item['server'].get('name') or '').lower() + if item['type'] == 'skill': + return (item['skill'].get('display_name') or item['skill'].get('name') or '').lower() + return '' + + extensions: list[dict] = [] + if isinstance(plugins, list): + for plugin in plugins: + extensions.append({'type': 'plugin', 'plugin': plugin}) + if isinstance(mcp_servers, list): + for server in mcp_servers: + extensions.append({'type': 'mcp', 'server': server}) + if isinstance(skills, list): + for skill in skills: + extensions.append({'type': 'skill', 'skill': skill}) + + extensions.sort(key=_sort_key) + + return self.success(data={'extensions': extensions}) diff --git a/src/langbot/pkg/api/http/controller/groups/monitoring.py b/src/langbot/pkg/api/http/controller/groups/monitoring.py index 11c9e2729..29dedcafb 100644 --- a/src/langbot/pkg/api/http/controller/groups/monitoring.py +++ b/src/langbot/pkg/api/http/controller/groups/monitoring.py @@ -46,6 +46,30 @@ class MonitoringRouterGroup(group.RouterGroup): return self.success(data=metrics) + @self.route('/token-statistics', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def get_token_statistics() -> str: + """Get detailed token usage statistics (summary, per-model, timeseries).""" + bot_ids = quart.request.args.getlist('botId') + pipeline_ids = quart.request.args.getlist('pipelineId') + start_time_str = quart.request.args.get('startTime') + end_time_str = quart.request.args.get('endTime') + bucket = quart.request.args.get('bucket', 'hour') + if bucket not in ('hour', 'day'): + bucket = 'hour' + + start_time = parse_iso_datetime(start_time_str) + end_time = parse_iso_datetime(end_time_str) + + stats = await self.ap.monitoring_service.get_token_statistics( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + bucket=bucket, + ) + + return self.success(data=stats) + @self.route('/messages', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def get_messages() -> str: """Get message logs""" @@ -114,6 +138,39 @@ class MonitoringRouterGroup(group.RouterGroup): } ) + @self.route('/tool-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def get_tool_calls() -> str: + """Get tool call records""" + bot_ids = quart.request.args.getlist('botId') + pipeline_ids = quart.request.args.getlist('pipelineId') + session_ids = quart.request.args.getlist('sessionId') + start_time_str = quart.request.args.get('startTime') + end_time_str = quart.request.args.get('endTime') + limit = int(quart.request.args.get('limit', 100)) + offset = int(quart.request.args.get('offset', 0)) + + start_time = parse_iso_datetime(start_time_str) + end_time = parse_iso_datetime(end_time_str) + + tool_calls, total = await self.ap.monitoring_service.get_tool_calls( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + session_ids=session_ids if session_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=offset, + ) + + return self.success( + data={ + 'tool_calls': tool_calls, + 'total': total, + 'limit': limit, + 'offset': offset, + } + ) + @self.route('/embedding-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def get_embedding_calls() -> str: """Get embedding call records""" @@ -260,6 +317,16 @@ class MonitoringRouterGroup(group.RouterGroup): offset=0, ) + # Get tool calls + tool_calls, tool_calls_total = await self.ap.monitoring_service.get_tool_calls( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=0, + ) + # Get sessions sessions, sessions_total = await self.ap.monitoring_service.get_sessions( bot_ids=bot_ids if bot_ids else None, @@ -294,12 +361,14 @@ class MonitoringRouterGroup(group.RouterGroup): 'overview': overview, 'messages': messages, 'llmCalls': llm_calls, + 'toolCalls': tool_calls, 'embeddingCalls': embedding_calls, 'sessions': sessions, 'errors': errors, 'totalCount': { 'messages': messages_total, 'llmCalls': llm_calls_total, + 'toolCalls': tool_calls_total, 'embeddingCalls': embedding_calls_total, 'sessions': sessions_total, 'errors': errors_total, diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py index e7fb61188..2e45add77 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -73,15 +73,25 @@ class PipelinesRouterGroup(group.RouterGroup): plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds) mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True) + # Get available skills + available_skills = await self.ap.skill_service.list_skills() + extensions_prefs = pipeline.get('extensions_preferences', {}) return self.success( data={ 'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True), 'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True), + 'enable_all_skills': extensions_prefs.get('enable_all_skills', True), 'bound_plugins': extensions_prefs.get('plugins', []), 'available_plugins': plugins, 'bound_mcp_servers': extensions_prefs.get('mcp_servers', []), 'available_mcp_servers': mcp_servers, + 'bound_mcp_resources': extensions_prefs.get('mcp_resources', []), + 'mcp_resource_agent_read_enabled': extensions_prefs.get( + 'mcp_resource_agent_read_enabled', True + ), + 'bound_skills': extensions_prefs.get('skills', []), + 'available_skills': available_skills, } ) elif quart.request.method == 'PUT': @@ -89,11 +99,23 @@ class PipelinesRouterGroup(group.RouterGroup): json_data = await quart.request.json enable_all_plugins = json_data.get('enable_all_plugins', True) enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True) + enable_all_skills = json_data.get('enable_all_skills', True) bound_plugins = json_data.get('bound_plugins', []) bound_mcp_servers = json_data.get('bound_mcp_servers', []) + bound_skills = json_data.get('bound_skills', []) + bound_mcp_resources = json_data.get('bound_mcp_resources') + mcp_resource_agent_read_enabled = json_data.get('mcp_resource_agent_read_enabled') await self.ap.pipeline_service.update_pipeline_extensions( - pipeline_uuid, bound_plugins, bound_mcp_servers, enable_all_plugins, enable_all_mcp_servers + pipeline_uuid, + bound_plugins, + bound_mcp_servers, + enable_all_plugins, + enable_all_mcp_servers, + bound_skills=bound_skills, + enable_all_skills=enable_all_skills, + bound_mcp_resources=bound_mcp_resources, + mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled, ) return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py index c85ecc779..ebe46b8fe 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py @@ -43,8 +43,12 @@ class WebSocketChatRouterGroup(group.RouterGroup): await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'})) return - # Find the owning bot for this pipeline (e.g. a web_page_bot) - owner_bot = self._find_owner_bot(pipeline_uuid) + # Dashboard pipeline-debug sessions must always run under the + # built-in websocket_proxy_bot identity. We deliberately do NOT + # resolve a web_page_bot owner here — even if one is bound to + # the same pipeline, debug requests must not be attributed to + # it. The embed widget path (`/api/v1/embed//ws/connect`) + # is the one that carries the page-bot identity. # 注册连接 connection = await ws_connection_manager.add_connection( @@ -73,7 +77,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): ) # 创建接收和发送任务 - receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, owner_bot)) + receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter)) send_task = asyncio.create_task(self._handle_send(connection)) # 等待任务完成 @@ -181,14 +185,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): except Exception as e: return self.http_status(500, -1, f'Internal server error: {str(e)}') - def _find_owner_bot(self, pipeline_uuid: str): - """Find a user-created bot (e.g. web_page_bot) that owns this pipeline.""" - for bot in self.ap.platform_mgr.bots: - if bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.use_pipeline_uuid == pipeline_uuid: - return bot - return None - - async def _handle_receive(self, connection, websocket_adapter, owner_bot=None): + async def _handle_receive(self, connection, websocket_adapter): """处理接收消息的任务""" try: while connection.is_active: @@ -213,7 +210,10 @@ class WebSocketChatRouterGroup(group.RouterGroup): logger.debug(f'收到消息: {data} from {connection.connection_id}') # 处理消息(不等待响应,响应会通过broadcast异步发送) - await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot) + # owner_bot is intentionally NOT passed: the dashboard + # debug WebSocket must always run under the proxy bot, + # never under a coincidentally-bound web_page_bot. + await websocket_adapter.handle_websocket_message(connection, data) elif message_type == 'disconnect': # 客户端主动断开 diff --git a/src/langbot/pkg/api/http/controller/groups/platform/bots.py b/src/langbot/pkg/api/http/controller/groups/platform/bots.py index ac580b1a3..e3a13b789 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/bots.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/bots.py @@ -18,7 +18,6 @@ class BotsRouterGroup(group.RouterGroup): @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(bot_uuid: str) -> str: if quart.request.method == 'GET': - # 返回运行时信息,包括webhook地址等 bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid) if bot is None: return self.http_status(404, -1, 'bot not found') @@ -37,30 +36,21 @@ class BotsRouterGroup(group.RouterGroup): from_index = json_data.get('from_index', -1) max_count = json_data.get('max_count', 10) logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count) - return self.success( - data={ - 'logs': logs, - 'total_count': total_count, - } - ) + return self.success(data={'logs': logs, 'total_count': total_count}) @self.route('//send_message', methods=['POST'], auth_type=group.AuthType.API_KEY) async def _(bot_uuid: str) -> str: - """Send message to a specific target via bot""" json_data = await quart.request.json target_type = json_data.get('target_type') target_id = json_data.get('target_id') message_chain_data = json_data.get('message_chain') - # Validate required fields if not target_type: return self.http_status(400, -1, 'target_type is required') if not target_id: return self.http_status(400, -1, 'target_id is required') if not message_chain_data: return self.http_status(400, -1, 'message_chain is required') - - # Validate target_type if target_type not in ['person', 'group']: return self.http_status(400, -1, 'target_type must be either "person" or "group"') @@ -72,3 +62,29 @@ class BotsRouterGroup(group.RouterGroup): traceback.print_exc() return self.http_status(500, -1, f'Failed to send message: {str(e)}') + + # ============ Bot Admins ============ + + @self.route('//admins', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(bot_uuid: str) -> str: + if quart.request.method == 'GET': + admins = await self.ap.bot_service.get_bot_admins(bot_uuid) + return self.success(data={'admins': admins}) + elif quart.request.method == 'POST': + json_data = await quart.request.json + launcher_type = json_data.get('launcher_type', '').strip() + launcher_id = str(json_data.get('launcher_id', '')).strip() + if not launcher_type or not launcher_id: + return self.http_status(400, -1, 'launcher_type and launcher_id are required') + try: + admin_id = await self.ap.bot_service.add_bot_admin(bot_uuid, launcher_type, launcher_id) + return self.success(data={'id': admin_id}) + except Exception as e: + return self.http_status(409, -1, str(e)) + + @self.route( + '//admins/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + ) + async def _(bot_uuid: str, admin_id: int) -> str: + await self.ap.bot_service.delete_bot_admin(bot_uuid, admin_id) + return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index 3de3e678b..c291c1232 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -1,11 +1,15 @@ from __future__ import annotations import base64 +import io import quart import re import httpx import uuid import os +import zipfile +import yaml +from urllib.parse import urlparse import posixpath import sqlalchemy @@ -53,6 +57,97 @@ def _get_request_origin() -> str: @group.group_class('plugins', '/api/v1/plugins') class PluginsRouterGroup(group.RouterGroup): + @staticmethod + def _normalize_archive_path(path: str) -> str: + normalized = str(path or '').replace('\\', '/').strip('/') + return posixpath.normpath(normalized) if normalized else '' + + @classmethod + def _component_source_path(cls, entry) -> str: + if isinstance(entry, dict): + return cls._normalize_archive_path(entry.get('path') or '') + return cls._normalize_archive_path(str(entry or '')) + + @classmethod + def _count_component_configs(cls, component_config, archive_names: list[str]) -> int: + normalized_names = [cls._normalize_archive_path(name) for name in archive_names] + component_files: set[str] = set() + + if isinstance(component_config, list): + return len(component_config) + if not isinstance(component_config, dict): + return 1 if component_config else 0 + + for entry in component_config.get('fromFiles') or []: + source_path = cls._component_source_path(entry) + if source_path and source_path in normalized_names: + component_files.add(source_path) + + for entry in component_config.get('fromDirs') or []: + source_dir = cls._component_source_path(entry).rstrip('/') + if not source_dir: + continue + prefix = f'{source_dir}/' + for archive_name in normalized_names: + if not archive_name.startswith(prefix): + continue + if archive_name.lower().endswith(('.yaml', '.yml')): + component_files.add(archive_name) + + if component_files: + return len(component_files) + + return 1 if any(key in component_config for key in ('path', 'name', 'kind')) else 0 + + @classmethod + def _count_plugin_components(cls, components, archive_names: list[str]) -> dict[str, int]: + if not isinstance(components, dict): + return {} + + component_counts: dict[str, int] = {} + for kind, component_config in components.items(): + count = cls._count_component_configs(component_config, archive_names) + if count > 0: + component_counts[str(kind)] = count + return component_counts + + @staticmethod + def _parse_github_repo_url(repo_url: str) -> dict | None: + raw_url = str(repo_url or '').strip() + if not raw_url: + return None + + if not re.match(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', raw_url): + raw_url = f'https://{raw_url}' + + parsed = urlparse(raw_url) + if parsed.netloc.lower() not in ('github.com', 'www.github.com'): + return None + + parts = [part for part in parsed.path.strip('/').split('/') if part] + if len(parts) < 2: + return None + + owner = parts[0] + repo = parts[1] + if repo.endswith('.git'): + repo = repo[:-4] + if not owner or not repo: + return None + + ref = '' + subdir = '' + if len(parts) >= 4 and parts[2] in ('tree', 'blob'): + ref = parts[3] + subdir = '/'.join(parts[4:]).strip('/') + + return { + 'owner': owner, + 'repo': repo, + 'ref': ref, + 'subdir': subdir, + } + async def _check_extensions_limit(self) -> str | None: """Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise.""" limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) @@ -176,6 +271,20 @@ class PluginsRouterGroup(group.RouterGroup): readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language) return self.success(data={'readme': readme}) + @self.route( + '///logs', + methods=['GET'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + ) + async def _(author: str, plugin_name: str) -> quart.Response: + try: + limit = int(quart.request.args.get('limit', 200)) + except (TypeError, ValueError): + limit = 200 + level = quart.request.args.get('level') or None + logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level) + return self.success(data={'logs': logs}) + @self.route( '///icon', methods=['GET'], @@ -254,17 +363,37 @@ class PluginsRouterGroup(group.RouterGroup): data = await quart.request.json repo_url = data.get('repo_url', '') - # Parse GitHub repository URL to extract owner and repo - # Supports: https://github.com/owner/repo or github.com/owner/repo - pattern = r'github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$' - match = re.search(pattern, repo_url) - - if not match: + parsed_repo = self._parse_github_repo_url(repo_url) + if not parsed_repo: return self.http_status(400, -1, 'Invalid GitHub repository URL') - owner, repo = match.groups() + owner = parsed_repo['owner'] + repo = parsed_repo['repo'] + requested_ref = parsed_repo['ref'] + requested_subdir = parsed_repo['subdir'] try: + if requested_ref: + return self.success( + data={ + 'releases': [ + { + 'id': 0, + 'tag_name': requested_ref, + 'name': requested_ref, + 'published_at': '', + 'prerelease': False, + 'draft': False, + 'source_type': 'branch', + 'archive_url': f'https://api.github.com/repos/{owner}/{repo}/zipball/{requested_ref}', + } + ], + 'owner': owner, + 'repo': repo, + 'source_subdir': requested_subdir, + } + ) + # Fetch releases from GitHub API url = f'https://api.github.com/repos/{owner}/{repo}/releases' async with httpx.AsyncClient( @@ -290,7 +419,14 @@ class PluginsRouterGroup(group.RouterGroup): } ) - return self.success(data={'releases': formatted_releases, 'owner': owner, 'repo': repo}) + return self.success( + data={ + 'releases': formatted_releases, + 'owner': owner, + 'repo': repo, + 'source_subdir': requested_subdir, + } + ) except httpx.RequestError as e: return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}') @@ -445,6 +581,62 @@ class PluginsRouterGroup(group.RouterGroup): return self.success(data={'task_id': wrapper.id}) + @self.route('/install/local/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + file = (await quart.request.files).get('file') + if file is None: + return self.http_status(400, -1, 'file is required') + + file_bytes = file.read() + try: + with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf: + names = [name for name in zf.namelist() if not name.endswith('/')] + manifest_name = next( + ( + name + for name in names + if name.replace('\\', '/').strip('/').lower() in ('manifest.yaml', 'manifest.yml') + ), + None, + ) + if manifest_name is None: + return self.http_status(400, -1, 'manifest.yaml is required') + + manifest = yaml.safe_load(zf.read(manifest_name).decode('utf-8')) or {} + requirements: list[str] = [] + requirements_name = next( + (name for name in names if name.replace('\\', '/').strip('/').lower() == 'requirements.txt'), + None, + ) + if requirements_name is not None: + requirements = [ + line.strip() + for line in zf.read(requirements_name).decode('utf-8', errors='ignore').splitlines() + if line.strip() and not line.strip().startswith('#') + ] + + spec = manifest.get('spec') or {} + components = spec.get('components') or {} + component_counts = self._count_plugin_components(components, names) + component_types = list(component_counts.keys()) + + return self.success( + data={ + 'filename': file.filename or 'local plugin', + 'size': len(file_bytes), + 'manifest': manifest, + 'metadata': manifest.get('metadata') or {}, + 'component_types': component_types, + 'component_counts': component_counts, + 'requirements': requirements, + 'file_count': len(names), + } + ) + except zipfile.BadZipFile: + return self.http_status(400, -1, 'invalid .lbpkg file') + except Exception as exc: + return self.http_status(500, -1, f'Failed to preview plugin package: {exc}') + @self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: """Upload a file for plugin configuration""" diff --git a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py index ac91abffd..27654e70e 100644 --- a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py +++ b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py @@ -2,6 +2,7 @@ from __future__ import annotations import quart import traceback +from urllib.parse import unquote from ... import group @@ -28,9 +29,12 @@ class MCPRouterGroup(group.RouterGroup): traceback.print_exc() return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}') - @self.route('/servers/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN) + @self.route( + '/servers/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN + ) async def _(server_name: str) -> str: """获取、更新或删除MCP服务器配置""" + server_name = unquote(server_name) server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name) if server_data is None: @@ -54,9 +58,72 @@ class MCPRouterGroup(group.RouterGroup): except Exception as e: return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}') - @self.route('/servers//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + @self.route('/servers//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _(server_name: str) -> str: """测试MCP服务器连接""" + server_name = unquote(server_name) server_data = await quart.request.json task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data) return self.success(data={'task_id': task_id}) + + @self.route('/servers//resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _(server_name: str) -> str: + """Get resources from an MCP server""" + server_name = unquote(server_name) + try: + resources = await self.ap.mcp_service.get_mcp_server_resources(server_name) + templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name) + runtime_info = await self.ap.mcp_service.get_runtime_info(server_name) + return self.success( + data={ + 'resources': resources, + 'resource_templates': templates, + 'resource_capabilities': (runtime_info or {}).get('resource_capabilities', {}), + } + ) + except Exception as e: + return self.http_status(500, -1, f'Failed to get resources: {str(e)}') + + @self.route( + '/servers//resource-templates', methods=['GET'], auth_type=group.AuthType.USER_TOKEN + ) + async def _(server_name: str) -> str: + """Get resource templates from an MCP server""" + server_name = unquote(server_name) + try: + templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name) + return self.success(data={'resource_templates': templates}) + except Exception as e: + return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}') + + @self.route('/servers//logs', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _(server_name: str) -> str: + """Get logs from an MCP server""" + server_name = unquote(server_name) + try: + limit = int(quart.request.args.get('limit', 200)) + except (TypeError, ValueError): + limit = 200 + limit = min(limit, 500) + level = quart.request.args.get('level') or None + logs = await self.ap.mcp_service.get_mcp_server_logs(server_name, limit=limit, level=level) + return self.success(data={'logs': logs}) + + @self.route('/servers//resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(server_name: str) -> str: + """Read a resource from an MCP server""" + server_name = unquote(server_name) + data = await quart.request.json + uri = data.get('uri') + if not uri: + return self.http_status(400, -1, 'URI is required') + try: + envelope = await self.ap.mcp_service.read_mcp_server_resource_envelope( + server_name, + uri, + max_bytes=data.get('max_bytes'), + include_blob=bool(data.get('include_blob', False)), + ) + return self.success(data=envelope) + except Exception as e: + return self.http_status(500, -1, f'Failed to read resource: {str(e)}') diff --git a/src/langbot/pkg/api/http/controller/groups/resources/tools.py b/src/langbot/pkg/api/http/controller/groups/resources/tools.py index de827e544..128a0647d 100644 --- a/src/langbot/pkg/api/http/controller/groups/resources/tools.py +++ b/src/langbot/pkg/api/http/controller/groups/resources/tools.py @@ -1,5 +1,7 @@ from __future__ import annotations +import quart + from ... import group @@ -9,25 +11,41 @@ class ToolsRouterGroup(group.RouterGroup): @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: """获取所有可用工具列表""" - tools = await self.ap.tool_mgr.get_all_tools() + pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id') + bound_plugins: list[str] | None = None + bound_mcp_servers: list[str] | None = None - tool_list = [] - for tool in tools: - tool_list.append( - { - 'name': tool.name, - 'description': tool.description, - 'human_desc': tool.human_desc, - 'parameters': tool.parameters, - } - ) + if pipeline_uuid: + pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid) + if pipeline is None: + return self.http_status(404, -1, 'pipeline not found') - return self.success(data={'tools': tool_list}) + extensions_prefs = pipeline.get('extensions_preferences', {}) or {} + if not extensions_prefs.get('enable_all_plugins', True): + bound_plugins = [ + f'{plugin.get("author", "")}/{plugin.get("name", "")}' + for plugin in extensions_prefs.get('plugins', []) + if isinstance(plugin, dict) and plugin.get('name') + ] + if not extensions_prefs.get('enable_all_mcp_servers', True): + bound_mcp_servers = [ + server for server in (extensions_prefs.get('mcp_servers', []) or []) if isinstance(server, str) + ] + + return self.success( + data={ + 'tools': await self.ap.tool_mgr.get_tool_catalog( + bound_plugins, + bound_mcp_servers, + include_skill_authoring=True, + ) + } + ) @self.route('/', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _(tool_name: str) -> str: """获取特定工具详情""" - tools = await self.ap.tool_mgr.get_all_tools() + tools = await self.ap.tool_mgr.get_all_tools(include_skill_authoring=True) for tool in tools: if tool.name == tool_name: diff --git a/src/langbot/pkg/api/http/controller/groups/skills.py b/src/langbot/pkg/api/http/controller/groups/skills.py new file mode 100644 index 000000000..946741d76 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/skills.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import quart + +from langbot_plugin.box.errors import BoxError + +from .. import group + + +@group.group_class('skills', '/api/v1/skills') +class SkillsRouterGroup(group.RouterGroup): + """Skills management API endpoints.""" + + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def list_or_create_skills() -> quart.Response: + if quart.request.method == 'GET': + try: + skills = await self.ap.skill_service.list_skills() + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + return self.success(data={'skills': skills}) + + data = await quart.request.json + if 'name' not in data or not data['name']: + return self.http_status(400, -1, 'Missing required field: name') + + try: + skill = await self.ap.skill_service.create_skill(data) + return self.success(data={'skill': skill}) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + + @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def get_update_delete_skill(skill_name: str) -> quart.Response: + if quart.request.method == 'GET': + try: + skill = await self.ap.skill_service.get_skill(skill_name) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + if not skill: + return self.http_status(404, -1, 'Skill not found') + return self.success(data={'skill': skill}) + + if quart.request.method == 'PUT': + data = await quart.request.json + try: + skill = await self.ap.skill_service.update_skill(skill_name, data) + return self.success(data={'skill': skill}) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + + try: + await self.ap.skill_service.delete_skill(skill_name) + return self.success() + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + + @self.route('//files', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def list_skill_files(skill_name: str) -> quart.Response: + """List files in skill package directory.""" + path = quart.request.args.get('path', '.').strip() + include_hidden = quart.request.args.get('include_hidden', 'false').lower() == 'true' + + try: + result = await self.ap.skill_service.list_skill_files( + skill_name, + path=path, + include_hidden=include_hidden, + ) + return self.success(data=result) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + + @self.route( + '//files/', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + ) + async def read_or_write_skill_file(skill_name: str, path: str) -> quart.Response: + """Read or write a file in skill package.""" + if quart.request.method == 'GET': + try: + result = await self.ap.skill_service.read_skill_file(skill_name, path) + return self.success(data=result) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + + # PUT - write file + data = await quart.request.json + content = data.get('content', '') + if content is None: + return self.http_status(400, -1, 'Missing required field: content') + + try: + result = await self.ap.skill_service.write_skill_file(skill_name, path, content) + return self.success(data=result) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + + @self.route('//preview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def preview_skill(skill_name: str) -> quart.Response: + skill = self.ap.skill_mgr.get_skill_by_name(skill_name) + if not skill: + return self.http_status(404, -1, 'Skill not found') + return self.success(data={'instructions': skill.get('instructions', '')}) + + @self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def install_skill_from_github() -> quart.Response: + data = await quart.request.json + required_fields = ['asset_url', 'owner', 'repo'] + for field in required_fields: + if field not in data or not data[field]: + return self.http_status(400, -1, f'Missing required field: {field}') + asset_url = str(data['asset_url']).strip().lower().split('?', 1)[0].split('#', 1)[0] + if not asset_url.endswith('skill.md') and not data.get('release_tag'): + return self.http_status(400, -1, 'Missing required field: release_tag') + + try: + skill = await self.ap.skill_service.install_from_github(data) + return self.success(data={'skills': skill}) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + except Exception as exc: + return self.http_status(500, -1, f'Failed to install skill: {exc}') + + @self.route('/install/github/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def preview_skill_from_github() -> quart.Response: + data = await quart.request.json + required_fields = ['asset_url', 'owner', 'repo'] + for field in required_fields: + if field not in data or not data[field]: + return self.http_status(400, -1, f'Missing required field: {field}') + asset_url = str(data['asset_url']).strip().lower().split('?', 1)[0].split('#', 1)[0] + if not asset_url.endswith('skill.md') and not data.get('release_tag'): + return self.http_status(400, -1, 'Missing required field: release_tag') + + try: + preview = await self.ap.skill_service.preview_install_from_github(data) + return self.success(data={'skills': preview}) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + except Exception as exc: + return self.http_status(500, -1, f'Failed to preview skill: {exc}') + + @self.route('/install/upload', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def install_skill_from_upload() -> quart.Response: + file = (await quart.request.files).get('file') + if file is None: + return self.http_status(400, -1, 'file is required') + form = await quart.request.form + + try: + skill = await self.ap.skill_service.install_from_zip_upload( + file_bytes=file.read(), + filename=file.filename or '', + source_paths=form.getlist('source_paths'), + ) + return self.success(data={'skills': skill}) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + except Exception as exc: + return self.http_status(500, -1, f'Failed to install skill: {exc}') + + @self.route('/install/upload/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def preview_skill_from_upload() -> quart.Response: + file = (await quart.request.files).get('file') + if file is None: + return self.http_status(400, -1, 'file is required') + + try: + preview = await self.ap.skill_service.preview_install_from_zip_upload( + file_bytes=file.read(), + filename=file.filename or '', + ) + return self.success(data={'skills': preview}) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + except Exception as exc: + return self.http_status(500, -1, f'Failed to preview skill: {exc}') + + @self.route('/scan', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def scan_skill_directory() -> quart.Response: + path = quart.request.args.get('path', '').strip() + if not path: + return self.http_status(400, -1, 'Missing required parameter: path') + + try: + result = await self.ap.skill_service.scan_directory_async(path) + return self.success(data=result) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) diff --git a/src/langbot/pkg/api/http/controller/groups/survey.py b/src/langbot/pkg/api/http/controller/groups/survey.py index dcfd7f9ee..a65d51a85 100644 --- a/src/langbot/pkg/api/http/controller/groups/survey.py +++ b/src/langbot/pkg/api/http/controller/groups/survey.py @@ -1,3 +1,5 @@ +import base64 + import quart from .. import group @@ -30,6 +32,50 @@ class SurveyRouterGroup(group.RouterGroup): return self.fail(2, 'Failed to submit response') return self.fail(3, 'Survey not available') + @self.route('/feedback', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _feedback(user_email: str) -> str: + """Submit on-demand user feedback from the sidebar.""" + json_data = await quart.request.get_json(silent=True) or {} + content = str(json_data.get('content', '')).strip() + attachments = json_data.get('attachments', []) + + if not content: + return self.fail(1, 'content required') + if len(content) > 5000: + return self.fail(2, 'content too long') + if not isinstance(attachments, list): + return self.fail(3, 'attachments must be an array') + if len(attachments) > 3: + return self.fail(4, 'too many attachments') + + normalized_attachments = [] + for item in attachments: + if not isinstance(item, dict): + continue + data_url = str(item.get('data_url', '')) + mime_type = str(item.get('mime_type', ''))[:128] + name = str(item.get('name', ''))[:255] + if not data_url.startswith('data:image/'): + continue + try: + payload = data_url.split(',', 1)[1] + if len(base64.b64decode(payload, validate=True)) > 1024 * 1024: + return self.fail(5, 'attachment too large') + except Exception: + return self.fail(5, 'attachment too large') + normalized_attachments.append({'name': name, 'mime_type': mime_type, 'data_url': data_url}) + + if self.ap.survey: + ok = await self.ap.survey.submit_feedback( + content=content, + attachments=normalized_attachments, + user_email=user_email, + ) + if ok: + return self.success() + return self.fail(6, 'Failed to submit feedback') + return self.fail(7, 'Survey not available') + @self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _dismiss() -> str: """Dismiss survey.""" diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index e6c10fc0d..236a23582 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -31,6 +31,18 @@ class SystemRouterGroup(group.RouterGroup): except Exception: pass + # ``system.outbound_ips`` may be a comma-separated string instead of + # a list when injected via the SYSTEM__OUTBOUND_IPS env var into a + # pre-existing data/config.yaml that lacks the key (env overrides + # only coerce to list when the key already holds one). + outbound_ips = self.ap.instance_config.data.get('system', {}).get('outbound_ips', []) + if isinstance(outbound_ips, str): + outbound_ips = [ip.strip() for ip in outbound_ips.split(',') if ip.strip()] + elif isinstance(outbound_ips, list): + outbound_ips = [str(ip).strip() for ip in outbound_ips if str(ip).strip()] + else: + outbound_ips = [] + return self.success( data={ 'version': constants.semantic_version, @@ -49,6 +61,7 @@ class SystemRouterGroup(group.RouterGroup): 'disable_models_service', False ), 'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}), + 'outbound_ips': outbound_ips, 'wizard_status': wizard_status, 'wizard_progress': wizard_progress, } diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index e86d6d1e2..886dc5d0d 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -195,6 +195,13 @@ class UserRouterGroup(group.RouterGroup): @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _(user_email: str) -> str: """Set password for Space account (first time) or change password""" + # Check if modifying login info is allowed + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + json_data = await quart.request.json new_password = json_data.get('new_password') current_password = json_data.get('current_password') diff --git a/src/langbot/pkg/api/http/controller/main.py b/src/langbot/pkg/api/http/controller/main.py index 2e366c3ef..835617e16 100644 --- a/src/langbot/pkg/api/http/controller/main.py +++ b/src/langbot/pkg/api/http/controller/main.py @@ -17,6 +17,7 @@ from .groups import platform as groups_platform from .groups import pipelines as groups_pipelines from .groups import knowledge as groups_knowledge from .groups import resources as groups_resources +from ...mcp.mount import MCPMount importutil.import_modules_in_pkg(groups) importutil.import_modules_in_pkg(groups_provider) @@ -39,6 +40,10 @@ class HTTPController: # Set maximum content length to prevent large file uploads self.quart_app.config['MAX_CONTENT_LENGTH'] = group.MAX_FILE_SIZE + # MCP server (mounted at /mcp, see ..mcp.mount). Built lazily in + # initialize() so the service layer is ready. + self.mcp_mount: MCPMount | None = None + async def initialize(self) -> None: # Register custom error handler for file size limit @self.quart_app.errorhandler(RequestEntityTooLarge) @@ -52,6 +57,12 @@ class HTTPController: await self.register_routes() + # Build the MCP server and start its session-manager lifespan in the + # background so the streamable-HTTP transport is ready to serve. + self.mcp_mount = MCPMount(self.ap) + await self.mcp_mount.start_session_manager() + self.ap.logger.info('LangBot MCP server mounted at /mcp (API-key authenticated).') + async def run(self) -> None: if True: @@ -61,7 +72,7 @@ class HTTPController: async def exception_handler(*args, **kwargs): try: - await self.quart_app.run_task(*args, **kwargs) + await self._run_task(*args, **kwargs) except Exception as e: self.ap.logger.error(f'Failed to start HTTP service: {e}') @@ -77,6 +88,28 @@ class HTTPController: # await asyncio.sleep(5) + async def _run_task(self, host: str, port: int, shutdown_trigger) -> None: + """Serve the Quart app, fronted by the MCP dispatcher at /mcp. + + Mirrors Quart.run_task() but wraps the ASGI app so MCP requests are + intercepted before Quart's router. Falls back to plain Quart if the + MCP mount failed to build for any reason. + """ + from hypercorn.config import Config as HyperConfig + from hypercorn.asyncio import serve as hypercorn_serve + + config = HyperConfig() + config.access_log_format = '%(h)s %(r)s %(s)s %(b)s %(D)s' + config.accesslog = '-' + config.bind = [f'{host}:{port}'] + config.errorlog = config.accesslog + + asgi_app = self.quart_app + if self.mcp_mount is not None: + asgi_app = self.mcp_mount.wrap(self.quart_app) + + await hypercorn_serve(asgi_app, config, shutdown_trigger=shutdown_trigger) + async def register_routes(self) -> None: @self.quart_app.route('/healthz') async def healthz(): diff --git a/src/langbot/pkg/api/http/service/apikey.py b/src/langbot/pkg/api/http/service/apikey.py index 5e6ff15d5..207254351 100644 --- a/src/langbot/pkg/api/http/service/apikey.py +++ b/src/langbot/pkg/api/http/service/apikey.py @@ -51,8 +51,25 @@ class ApiKeyService: return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key) async def verify_api_key(self, key: str) -> bool: - """Verify if an API key is valid""" - if not isinstance(key, str) or not key.startswith('lbk_'): + """Verify if an API key is valid. + + A key is accepted if it matches the global API key configured in + ``config.yaml`` (``api.global_api_key``) — which requires no login + session and no database record — or if it matches a key created via + the web UI (stored in the database, prefixed with ``lbk_``). + """ + if not isinstance(key, str) or not key: + return False + + # 1. Global API key from config.yaml (no DB lookup, no login state). + # Note: config completion only backfills top-level keys, so existing + # installs may not have this key — access it defensively. + global_api_key = self.ap.instance_config.data.get('api', {}).get('global_api_key', '') + if global_api_key and secrets.compare_digest(key, global_api_key): + return True + + # 2. Web-UI-created keys are stored in the database and prefixed lbk_. + if not key.startswith('lbk_'): return False result = await self.ap.persistence_mgr.execute_async( diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index b8af08613..995267cf5 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -199,3 +199,35 @@ class BotService: # Send message via adapter await runtime_bot.adapter.send_message(target_type, str(target_id), message_chain) + + # ============ Bot Admins ============ + + async def get_bot_admins(self, bot_uuid: str) -> list[dict]: + from ....entity.persistence import bot as persistence_bot + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_bot.BotAdmin).where(persistence_bot.BotAdmin.bot_uuid == bot_uuid) + ) + return [{'id': r.id, 'launcher_type': r.launcher_type, 'launcher_id': r.launcher_id} for r in result.all()] + + async def add_bot_admin(self, bot_uuid: str, launcher_type: str, launcher_id: str) -> int: + from ....entity.persistence import bot as persistence_bot + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_bot.BotAdmin).values( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + ) + ) + return result.inserted_primary_key[0] + + async def delete_bot_admin(self, bot_uuid: str, admin_id: int) -> None: + from ....entity.persistence import bot as persistence_bot + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_bot.BotAdmin).where( + persistence_bot.BotAdmin.bot_uuid == bot_uuid, + persistence_bot.BotAdmin.id == admin_id, + ) + ) diff --git a/src/langbot/pkg/api/http/service/maintenance.py b/src/langbot/pkg/api/http/service/maintenance.py index e755800ee..fa7359cba 100644 --- a/src/langbot/pkg/api/http/service/maintenance.py +++ b/src/langbot/pkg/api/http/service/maintenance.py @@ -243,6 +243,7 @@ class MaintenanceService: tables = { 'messages': persistence_monitoring.MonitoringMessage.id, 'llm_calls': persistence_monitoring.MonitoringLLMCall.id, + 'tool_calls': persistence_monitoring.MonitoringToolCall.id, 'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id, 'errors': persistence_monitoring.MonitoringError.id, 'sessions': persistence_monitoring.MonitoringSession.session_id, diff --git a/src/langbot/pkg/api/http/service/mcp.py b/src/langbot/pkg/api/http/service/mcp.py index aadbcf116..1dbceb6e5 100644 --- a/src/langbot/pkg/api/http/service/mcp.py +++ b/src/langbot/pkg/api/http/service/mcp.py @@ -48,6 +48,17 @@ class MCPService: if total_extensions >= max_extensions: raise ValueError(f'Maximum number of extensions ({max_extensions}) reached') + server_name = str(server_data.get('name') or '').strip() + if not server_name: + raise ValueError('MCP server name is required') + server_data['name'] = server_name + + existing_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name) + ) + if existing_result.first() is not None: + raise ValueError(f'MCP server already exists: {server_name}') + server_data['uuid'] = str(uuid.uuid4()) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data)) @@ -136,25 +147,95 @@ class MCPService: if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions: await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name) + async def get_mcp_server_resources(self, server_name: str) -> list[dict]: + """Get resources from a specific MCP server.""" + return await self.ap.tool_mgr.mcp_tool_loader.get_resources(server_name) + + async def get_mcp_server_resource_templates(self, server_name: str) -> list[dict]: + """Get resource templates from a specific MCP server.""" + return await self.ap.tool_mgr.mcp_tool_loader.get_resource_templates(server_name) + + async def read_mcp_server_resource_envelope( + self, + server_name: str, + uri: str, + *, + max_bytes: int | None = None, + include_blob: bool = False, + ) -> dict: + """Read a resource from a specific MCP server with metadata.""" + kwargs = {'include_blob': include_blob, 'source': 'ui_preview'} + if max_bytes is not None: + kwargs['max_bytes'] = max_bytes + return await self.ap.tool_mgr.mcp_tool_loader.read_resource_envelope(server_name, uri, **kwargs) + + async def read_mcp_server_resource(self, server_name: str, uri: str) -> list[dict]: + """Read a resource from a specific MCP server.""" + return await self.ap.tool_mgr.mcp_tool_loader.read_resource(server_name, uri) + async def test_mcp_server(self, server_name: str, server_data: dict) -> int: """测试 MCP 服务器连接并返回任务 ID""" runtime_mcp_session: RuntimeMCPSession | None = None + ctx = taskmgr.TaskContext.new() + if server_name != '_': runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name) if runtime_mcp_session is None: raise ValueError(f'Server not found: {server_name}') - if runtime_mcp_session.status == MCPSessionStatus.ERROR: - coroutine = runtime_mcp_session.start() - else: - coroutine = runtime_mcp_session.refresh() + persisted_session = runtime_mcp_session + + async def _refresh_and_report() -> None: + # Testing a persisted server should REUSE its live shared-session + # process, not rebuild it. Try a lightweight refresh (a real + # list_tools probe over the existing connection) first; only fall + # back to a full start() when the session has no live connection + # to probe (never connected, or the process is actually gone). + needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None + if needs_start: + await persisted_session.start() + else: + try: + await persisted_session.refresh() + except Exception: + # The live connection was stale/dropped: reconnect once + # (reusing the live managed process where possible) and + # re-probe, instead of reporting a false failure. + await persisted_session.start() + # Surface the discovered tools so the config page can render them + # even for an already-hosted server. + ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict() + + coroutine = _refresh_and_report() else: runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data) - coroutine = runtime_mcp_session.start() - ctx = taskmgr.TaskContext.new() + # A transient test owns an isolated Box session. Always tear it down + # after the test completes (success or failure) so it does not leak. + test_session = runtime_mcp_session + + async def _run_and_cleanup() -> None: + try: + await test_session.start() + # Capture the runtime info (status + discovered tools) BEFORE + # shutting the transient session down. The create/edit config + # page has no persisted server to reload from, so without this + # a successful test could only show "no tools found". The + # frontend reads ctx.metadata.runtime_info to render the tools. + ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict() + finally: + try: + await test_session.shutdown() + except Exception as exc: + self.ap.logger.warning( + f'Failed to tear down transient MCP test session ' + f'{test_session.server_name}: {type(exc).__name__}: {exc}' + ) + + coroutine = _run_and_cleanup() + wrapper = self.ap.task_mgr.create_user_task( coroutine, kind='mcp-operation', @@ -163,3 +244,19 @@ class MCPService: context=ctx, ) return wrapper.id + + async def get_mcp_server_logs(self, server_name: str, limit: int = 200, level: str | None = None) -> list[dict]: + """Get recent log lines captured from the MCP server's stderr.""" + session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name) + if not session: + return [] + + # Get logs from the session's buffer + logs = list(session._log_buffer) + + # Filter by level if specified + if level: + logs = [log for log in logs if log.get('level') == level] + + # Return the most recent 'limit' logs + return logs[-limit:] diff --git a/src/langbot/pkg/api/http/service/model.py b/src/langbot/pkg/api/http/service/model.py index 320104d86..87298c084 100644 --- a/src/langbot/pkg/api/http/service/model.py +++ b/src/langbot/pkg/api/http/service/model.py @@ -34,6 +34,46 @@ def _runtime_model_data(model_uuid: str, model_data: dict) -> dict: return {**model_data, 'uuid': model_uuid} +async def _validate_provider_supports(ap: app.Application, provider_uuid: str, model_type: str) -> None: + """Validate that the provider's requester declares support for ``model_type``. + + ``model_type`` is one of the manifest ``support_type`` values: + 'llm', 'text-embedding', 'rerank'. Raises ValueError when the requester + manifest does not list the requested type. This is a server-side guard so + a model cannot be attached to a provider that does not support it, even if + the frontend tab restriction is bypassed. + """ + model_mgr = getattr(ap, 'model_mgr', None) + if model_mgr is None: + return + + provider_dict = getattr(model_mgr, 'provider_dict', None) + if not provider_dict: + return + runtime_provider = provider_dict.get(provider_uuid) + if runtime_provider is None: + return + + requester_name = getattr(getattr(runtime_provider, 'provider_entity', None), 'requester', None) + if not requester_name: + return + + get_manifest = getattr(model_mgr, 'get_available_requester_manifest_by_name', None) + if not callable(get_manifest): + return + manifest = get_manifest(requester_name) + if manifest is None: + return + + spec = getattr(manifest, 'spec', None) or {} + support_type = spec.get('support_type') if isinstance(spec, dict) else None + # When a manifest omits support_type, do not block (backward compatible). + if not support_type: + return + if model_type not in support_type: + raise ValueError(f'Provider requester "{requester_name}" does not support {model_type} models') + + class LLMModelsService: ap: app.Application @@ -96,6 +136,8 @@ class LLMModelsService: ) model_data['provider_uuid'] = provider_uuid + await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'llm') + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data)) runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid']) @@ -274,6 +316,8 @@ class EmbeddingModelsService: ) model_data['provider_uuid'] = provider_uuid + await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'text-embedding') + await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_model.EmbeddingModel).values(**model_data) ) @@ -434,6 +478,8 @@ class RerankModelsService: ) model_data['provider_uuid'] = provider_uuid + await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'rerank') + await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_model.RerankModel).values(**model_data) ) diff --git a/src/langbot/pkg/api/http/service/monitoring.py b/src/langbot/pkg/api/http/service/monitoring.py index 1ba66482b..46a352ad1 100644 --- a/src/langbot/pkg/api/http/service/monitoring.py +++ b/src/langbot/pkg/api/http/service/monitoring.py @@ -2,6 +2,7 @@ from __future__ import annotations import uuid import datetime +import json import sqlalchemy from ....core import app @@ -50,6 +51,12 @@ class MonitoringService: persistence_monitoring.MonitoringLLMCall.timestamp, persistence_monitoring.MonitoringLLMCall.id, ), + ( + 'monitoring_tool_calls', + persistence_monitoring.MonitoringToolCall, + persistence_monitoring.MonitoringToolCall.timestamp, + persistence_monitoring.MonitoringToolCall.id, + ), ( 'monitoring_embedding_calls', persistence_monitoring.MonitoringEmbeddingCall, @@ -131,6 +138,68 @@ class MonitoringService: await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)')) await autocommit_conn.execute(sqlalchemy.text('VACUUM')) + def _serialize_tool_payload(self, payload: object, max_length: int = 20000) -> str | None: + """Serialize tool arguments/results for monitoring storage.""" + if payload is None: + return None + + if isinstance(payload, str): + text = payload + else: + try: + text = json.dumps(payload, ensure_ascii=False, default=str) + except Exception: + text = str(payload) + + if len(text) <= max_length: + return text + + return f'{text[:max_length]}... [truncated {len(text) - max_length} chars]' + + async def _get_message_for_tool_context( + self, + message_id: str | None = None, + session_id: str | None = None, + ): + if message_id: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_monitoring.MonitoringMessage).where( + persistence_monitoring.MonitoringMessage.id == message_id + ) + ) + row = result.first() + if row: + return row[0] + + if not session_id: + return None + + user_query = ( + sqlalchemy.select(persistence_monitoring.MonitoringMessage) + .where( + sqlalchemy.and_( + persistence_monitoring.MonitoringMessage.session_id == session_id, + persistence_monitoring.MonitoringMessage.role == 'user', + ) + ) + .order_by(persistence_monitoring.MonitoringMessage.timestamp.desc()) + .limit(1) + ) + result = await self.ap.persistence_mgr.execute_async(user_query) + row = result.first() + if row: + return row[0] + + any_query = ( + sqlalchemy.select(persistence_monitoring.MonitoringMessage) + .where(persistence_monitoring.MonitoringMessage.session_id == session_id) + .order_by(persistence_monitoring.MonitoringMessage.timestamp.desc()) + .limit(1) + ) + result = await self.ap.persistence_mgr.execute_async(any_query) + row = result.first() + return row[0] if row else None + # ========== Recording Methods ========== async def record_message( @@ -220,6 +289,57 @@ class MonitoringService: return call_id + async def record_tool_call( + self, + tool_name: str, + tool_source: str, + duration: int, + status: str = 'success', + bot_id: str | None = None, + bot_name: str | None = None, + pipeline_id: str | None = None, + pipeline_name: str | None = None, + session_id: str | None = None, + message_id: str | None = None, + arguments: object | None = None, + result: object | None = None, + error_message: str | None = None, + ) -> str: + """Record a tool call.""" + context_message = await self._get_message_for_tool_context(message_id=message_id, session_id=session_id) + if context_message: + bot_id = bot_id or context_message.bot_id + bot_name = bot_name or context_message.bot_name + pipeline_id = pipeline_id or context_message.pipeline_id + pipeline_name = pipeline_name or context_message.pipeline_name + session_id = session_id or context_message.session_id + message_id = message_id or context_message.id + + call_id = str(uuid.uuid4()) + call_data = { + 'id': call_id, + 'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), + 'tool_name': tool_name, + 'tool_source': tool_source, + 'duration': max(0, duration), + 'status': status, + 'bot_id': bot_id or 'unknown', + 'bot_name': bot_name or 'Unknown', + 'pipeline_id': pipeline_id or 'unknown', + 'pipeline_name': pipeline_name or 'Unknown', + 'session_id': session_id, + 'message_id': message_id, + 'arguments': self._serialize_tool_payload(arguments), + 'result': self._serialize_tool_payload(result), + 'error_message': self._serialize_tool_payload(error_message), + } + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_monitoring.MonitoringToolCall).values(call_data) + ) + + return call_id + async def record_embedding_call( self, model_name: str, @@ -472,6 +592,179 @@ class MonitoringService: 'active_sessions': active_sessions, } + async def get_token_statistics( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + bucket: str = 'hour', + ) -> dict: + """Get detailed token usage statistics for production observability. + + Returns: + - summary: aggregate token counters and call/latency stats over the window + - by_model: per-model token + call breakdown (sorted by total tokens desc) + - timeseries: token usage bucketed by `bucket` ('hour' or 'day') + + Only successful LLM calls are counted toward token totals; error calls are + reported separately so a spike in failures is visible without polluting + token accounting. + """ + LLMCall = persistence_monitoring.MonitoringLLMCall + + conditions = [] + if bot_ids: + conditions.append(LLMCall.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(LLMCall.pipeline_id.in_(pipeline_ids)) + if start_time: + conditions.append(LLMCall.timestamp >= start_time) + if end_time: + conditions.append(LLMCall.timestamp <= end_time) + + def _apply(query): + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + return query + + # ---- Summary aggregates ---- + summary_query = _apply( + sqlalchemy.select( + sqlalchemy.func.count(LLMCall.id), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.input_tokens), 0), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.output_tokens), 0), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.duration), 0), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.cost), 0.0), + sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'success', 1), else_=0)), + sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'error', 1), else_=0)), + # Count of successful calls that nonetheless recorded zero tokens — + # a data-quality signal that usage reporting may be broken upstream. + sqlalchemy.func.sum( + sqlalchemy.case( + (sqlalchemy.and_(LLMCall.status == 'success', LLMCall.total_tokens == 0), 1), + else_=0, + ) + ), + ) + ) + summary_result = await self.ap.persistence_mgr.execute_async(summary_query) + row = summary_result.first() + ( + total_calls, + total_input_tokens, + total_output_tokens, + total_tokens, + total_duration, + total_cost, + success_calls, + error_calls, + zero_token_success_calls, + ) = row if row else (0, 0, 0, 0, 0, 0.0, 0, 0, 0) + + total_calls = total_calls or 0 + success_calls = success_calls or 0 + error_calls = error_calls or 0 + zero_token_success_calls = zero_token_success_calls or 0 + + summary = { + 'total_calls': total_calls, + 'success_calls': success_calls, + 'error_calls': error_calls, + 'total_input_tokens': int(total_input_tokens or 0), + 'total_output_tokens': int(total_output_tokens or 0), + 'total_tokens': int(total_tokens or 0), + 'total_cost': round(float(total_cost or 0.0), 6), + 'avg_tokens_per_call': int((total_tokens or 0) / total_calls) if total_calls > 0 else 0, + 'avg_duration_ms': int((total_duration or 0) / total_calls) if total_calls > 0 else 0, + 'avg_tokens_per_second': round((total_output_tokens or 0) / (total_duration / 1000), 2) + if total_duration and total_duration > 0 + else 0, + 'zero_token_success_calls': zero_token_success_calls, + } + + # ---- Per-model breakdown ---- + by_model_query = _apply( + sqlalchemy.select( + LLMCall.model_name, + sqlalchemy.func.count(LLMCall.id), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.input_tokens), 0), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.output_tokens), 0), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.duration), 0), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.cost), 0.0), + sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'error', 1), else_=0)), + ).group_by(LLMCall.model_name) + ) + by_model_result = await self.ap.persistence_mgr.execute_async(by_model_query) + by_model = [] + for mrow in by_model_result.all(): + ( + model_name, + m_calls, + m_in, + m_out, + m_total, + m_duration, + m_cost, + m_errors, + ) = mrow + m_calls = m_calls or 0 + by_model.append( + { + 'model_name': model_name, + 'calls': m_calls, + 'error_calls': m_errors or 0, + 'input_tokens': int(m_in or 0), + 'output_tokens': int(m_out or 0), + 'total_tokens': int(m_total or 0), + 'cost': round(float(m_cost or 0.0), 6), + 'avg_tokens_per_call': int((m_total or 0) / m_calls) if m_calls > 0 else 0, + 'avg_duration_ms': int((m_duration or 0) / m_calls) if m_calls > 0 else 0, + } + ) + by_model.sort(key=lambda x: x['total_tokens'], reverse=True) + + # ---- Time-bucketed series ---- + # Use a DB-agnostic bucketing approach: fetch (timestamp, tokens) rows and + # aggregate in Python. The window is bounded by the time filter, so this is + # cheap for typical dashboard ranges (hours/days). + series_query = _apply( + sqlalchemy.select( + LLMCall.timestamp, + LLMCall.input_tokens, + LLMCall.output_tokens, + LLMCall.total_tokens, + ).order_by(LLMCall.timestamp.asc()) + ) + series_result = await self.ap.persistence_mgr.execute_async(series_query) + + bucket_fmt = '%Y-%m-%d %H:00' if bucket == 'hour' else '%Y-%m-%d' + buckets: dict[str, dict] = {} + for srow in series_result.all(): + ts, s_in, s_out, s_total = srow + if ts is None: + continue + key = ts.strftime(bucket_fmt) + b = buckets.setdefault( + key, + {'bucket': key, 'input_tokens': 0, 'output_tokens': 0, 'total_tokens': 0, 'calls': 0}, + ) + b['input_tokens'] += int(s_in or 0) + b['output_tokens'] += int(s_out or 0) + b['total_tokens'] += int(s_total or 0) + b['calls'] += 1 + + timeseries = [buckets[k] for k in sorted(buckets.keys())] + + return { + 'summary': summary, + 'by_model': by_model, + 'timeseries': timeseries, + 'bucket': bucket, + } + async def get_messages( self, bot_ids: list[str] | None = None, @@ -576,6 +869,58 @@ class MonitoringService: total, ) + async def get_tool_calls( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + session_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 100, + offset: int = 0, + ) -> tuple[list[dict], int]: + """Get tool calls with filters""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringToolCall.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringToolCall.pipeline_id.in_(pipeline_ids)) + if session_ids: + conditions.append(persistence_monitoring.MonitoringToolCall.session_id.in_(session_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time) + + count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringToolCall.id)) + if conditions: + count_query = count_query.where(sqlalchemy.and_(*conditions)) + + count_result = await self.ap.persistence_mgr.execute_async(count_query) + total = count_result.scalar() or 0 + + query = sqlalchemy.select(persistence_monitoring.MonitoringToolCall).order_by( + persistence_monitoring.MonitoringToolCall.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit).offset(offset) + + result = await self.ap.persistence_mgr.execute_async(query) + tool_calls_rows = result.all() + + return ( + [ + self.ap.persistence_mgr.serialize_model( + persistence_monitoring.MonitoringToolCall, row[0] if isinstance(row, tuple) else row + ) + for row in tool_calls_rows + ], + total, + ) + async def get_embedding_calls( self, start_time: datetime.datetime | None = None, @@ -798,6 +1143,34 @@ class MonitoringService: else: error_llm_calls += 1 + # Get tool calls for this session + tool_query = ( + sqlalchemy.select(persistence_monitoring.MonitoringToolCall) + .where(persistence_monitoring.MonitoringToolCall.session_id == session_id) + .order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc()) + ) + tool_result = await self.ap.persistence_mgr.execute_async(tool_query) + tool_rows = tool_result.all() + + tool_calls = [ + self.ap.persistence_mgr.serialize_model( + persistence_monitoring.MonitoringToolCall, row[0] if isinstance(row, tuple) else row + ) + for row in tool_rows + ] + + total_tool_calls = len(tool_rows) + success_tool_calls = 0 + error_tool_calls = 0 + total_tool_duration = 0 + for row in tool_rows: + tool_call = row[0] if isinstance(row, tuple) else row + total_tool_duration += tool_call.duration + if tool_call.status == 'success': + success_tool_calls += 1 + else: + error_tool_calls += 1 + # Get errors for this session error_query = ( sqlalchemy.select(persistence_monitoring.MonitoringError) @@ -841,6 +1214,14 @@ class MonitoringService: 'total_tokens': total_tokens, 'average_duration_ms': int(total_duration / total_llm_calls) if total_llm_calls > 0 else 0, }, + 'tool_calls': tool_calls, + 'tool_stats': { + 'total_calls': total_tool_calls, + 'success_calls': success_tool_calls, + 'error_calls': error_tool_calls, + 'total_duration_ms': total_tool_duration, + 'average_duration_ms': int(total_tool_duration / total_tool_calls) if total_tool_calls > 0 else 0, + }, 'errors': errors, 'session_duration_seconds': session_duration_seconds, } diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index 9175aba55..2a6451c8b 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -100,6 +100,8 @@ class PipelineService: 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': [], + 'mcp_resources': [], + 'mcp_resource_agent_read_enabled': True, } await self.ap.persistence_mgr.execute_async( @@ -193,6 +195,8 @@ class PipelineService: 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': [], + 'mcp_resources': [], + 'mcp_resource_agent_read_enabled': True, } ), } @@ -215,6 +219,10 @@ class PipelineService: bound_mcp_servers: list[str] = None, enable_all_plugins: bool = True, enable_all_mcp_servers: bool = True, + bound_skills: list[str] = None, + enable_all_skills: bool = True, + bound_mcp_resources: list[dict] = None, + mcp_resource_agent_read_enabled: bool | None = None, ) -> None: """Update the bound plugins and MCP servers for a pipeline""" # Get current pipeline @@ -232,9 +240,16 @@ class PipelineService: extensions_preferences = pipeline.extensions_preferences or {} extensions_preferences['enable_all_plugins'] = enable_all_plugins extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers + extensions_preferences['enable_all_skills'] = enable_all_skills extensions_preferences['plugins'] = bound_plugins + if mcp_resource_agent_read_enabled is not None: + extensions_preferences['mcp_resource_agent_read_enabled'] = mcp_resource_agent_read_enabled if bound_mcp_servers is not None: extensions_preferences['mcp_servers'] = bound_mcp_servers + if bound_skills is not None: + extensions_preferences['skills'] = bound_skills + if bound_mcp_resources is not None: + extensions_preferences['mcp_resources'] = bound_mcp_resources await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_pipeline.LegacyPipeline) diff --git a/src/langbot/pkg/api/http/service/skill.py b/src/langbot/pkg/api/http/service/skill.py new file mode 100644 index 000000000..94b926975 --- /dev/null +++ b/src/langbot/pkg/api/http/service/skill.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +import io +import inspect +import os +import posixpath +import zipfile +from typing import Optional +from urllib.parse import quote, unquote, urlparse + +import httpx + +from ....core import app +from ....skill.utils import parse_frontmatter + + +_PUBLIC_SKILL_FIELDS = ( + 'name', + 'display_name', + 'description', + 'instructions', + 'package_root', + 'created_at', + 'updated_at', +) + +_GITHUB_ASSET_HOSTS = { + 'github.com', + 'api.github.com', + 'objects.githubusercontent.com', + 'githubusercontent.com', + 'raw.githubusercontent.com', + 'codeload.github.com', +} + + +class SkillService: + """Filesystem-backed skill management service.""" + + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + def _box_service(self): + box_service = getattr(self.ap, 'box_service', None) + if box_service is not None and getattr(box_service, 'available', False): + return box_service + return None + + def _require_box(self, action: str): + """Return the Box service or raise if it is not available. + + Box is the only source of truth for skills. Every read and write + operation goes through it — there is no local-filesystem fallback. + """ + box_service = self._box_service() + if box_service is not None: + return box_service + ap_box = getattr(self.ap, 'box_service', None) + if ap_box is None: + reason = 'not initialised' + elif not getattr(ap_box, 'enabled', True): + reason = 'disabled in config (box.enabled = false)' + else: + connector_error = getattr(ap_box, '_connector_error', '') or 'currently unavailable' + reason = f'unavailable: {connector_error}' + raise ValueError( + f'{action} requires the Box runtime, which is {reason}. ' + f'Enable Box in config.yaml (box.enabled = true) and ensure the ' + f'runtime is reachable before retrying.' + ) + + def _require_box_for_write(self, action: str) -> None: + """Backwards-compatible alias preserved for clarity at call sites.""" + self._require_box(action) + + @staticmethod + def _serialize_skill(skill: dict) -> dict: + return {field: skill.get(field) for field in _PUBLIC_SKILL_FIELDS if field in skill} + + async def list_skills(self) -> list[dict]: + # When Box is unavailable, surface an empty list rather than raising — + # the skills page should render cleanly, and the UI separately renders + # a "Box disabled / unavailable" banner via useBoxStatus. + box_service = self._box_service() + if box_service is None: + return [] + return [self._serialize_skill(skill) for skill in await box_service.list_skills()] + + async def get_skill(self, skill_name: str) -> Optional[dict]: + box_service = self._box_service() + if box_service is None: + return None + skill = await box_service.get_skill(skill_name) + return self._serialize_skill(skill) if skill else None + + async def get_skill_by_name(self, name: str) -> Optional[dict]: + return await self.get_skill(name) + + async def create_skill(self, data: dict) -> dict: + box_service = self._require_box('Creating a skill') + created = await box_service.create_skill(data) + await self._reload_skills() + return self._serialize_skill(created) + + async def update_skill(self, skill_name: str, data: dict) -> dict: + box_service = self._require_box('Editing a skill') + updated = await box_service.update_skill(skill_name, data) + await self._reload_skills() + return self._serialize_skill(updated) + + async def delete_skill(self, skill_name: str) -> bool: + box_service = self._require_box('Deleting a skill') + await box_service.delete_skill(skill_name) + await self._reload_skills() + return True + + async def list_skill_files( + self, + skill_name: str, + path: str = '.', + include_hidden: bool = False, + max_entries: int = 200, + ) -> dict: + box_service = self._require_box('Browsing skill files') + return await box_service.list_skill_files(skill_name, path, include_hidden, max_entries) + + async def read_skill_file(self, skill_name: str, path: str) -> dict: + box_service = self._require_box('Reading a skill file') + return await box_service.read_skill_file(skill_name, path) + + async def write_skill_file(self, skill_name: str, path: str, content: str) -> dict: + box_service = self._require_box('Editing skill files') + result = await box_service.write_skill_file(skill_name, path, content) + await self._reload_skills() + return result + + async def install_from_github(self, data: dict) -> list[dict]: + box_service = self._require_box('Installing a skill from GitHub') + owner = str(data['owner']).strip() + repo = str(data['repo']).strip() + release_tag = str(data.get('release_tag', '')).strip() + raw_asset_url = str(data['asset_url']).strip() + if self._is_github_skill_md_url(raw_asset_url): + return await self._install_github_skill_md(raw_asset_url, owner=owner, repo=repo, data=data) + + asset_url = self._validate_github_asset_url(raw_asset_url, owner=owner, repo=repo, release_tag=release_tag) + source_subdir = str(data.get('source_subdir', '') or '').strip() + + zip_bytes = await self._download_github_asset(asset_url) + filename = f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip' + installed = await box_service.install_skill_zip( + zip_bytes, + filename, + source_paths=data.get('source_paths') or [], + source_path=str(data.get('source_path', '') or ''), + source_subdir=source_subdir, + ) + await self._reload_skills() + return [self._serialize_skill(skill) for skill in installed] + + async def preview_install_from_github(self, data: dict) -> list[dict]: + box_service = self._require_box('Previewing a skill from GitHub') + owner = str(data['owner']).strip() + repo = str(data['repo']).strip() + release_tag = str(data.get('release_tag', '')).strip() + raw_asset_url = str(data['asset_url']).strip() + if self._is_github_skill_md_url(raw_asset_url): + return await self._preview_github_skill_md(raw_asset_url, owner=owner, repo=repo) + + asset_url = self._validate_github_asset_url(raw_asset_url, owner=owner, repo=repo, release_tag=release_tag) + source_subdir = str(data.get('source_subdir', '') or '').strip() + + zip_bytes = await self._download_github_asset(asset_url) + return await box_service.preview_skill_zip( + zip_bytes, + f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip', + source_subdir=source_subdir, + ) + + async def install_from_zip_upload( + self, + *, + file_bytes: bytes, + filename: str, + source_paths: list[str] | None = None, + source_path: str = '', + ) -> list[dict]: + box_service = self._require_box('Installing a skill from upload') + installed = await box_service.install_skill_zip( + file_bytes, + filename, + source_paths=source_paths or [], + source_path=source_path, + ) + await self._reload_skills() + return [self._serialize_skill(skill) for skill in installed] + + async def preview_install_from_zip_upload(self, *, file_bytes: bytes, filename: str) -> list[dict]: + box_service = self._require_box('Previewing a skill upload') + return await box_service.preview_skill_zip(file_bytes, filename) + + async def _install_github_skill_md(self, asset_url: str, *, owner: str, repo: str, data: dict) -> list[dict]: + box_service = self._require_box('Installing a skill from GitHub') + zip_bytes, filename, _package_name = await self._download_github_skill_directory_as_zip( + asset_url, + owner=owner, + repo=repo, + ) + + installed = await box_service.install_skill_zip( + zip_bytes, + filename, + source_paths=data.get('source_paths') or [], + source_path=str(data.get('source_path', '') or ''), + target_suffix='', + ) + await self._reload_skills() + return [self._serialize_skill(skill) for skill in installed] + + async def _preview_github_skill_md(self, asset_url: str, *, owner: str, repo: str) -> list[dict]: + box_service = self._require_box('Previewing a skill from GitHub') + zip_bytes, _filename, package_name = await self._download_github_skill_directory_as_zip( + asset_url, + owner=owner, + repo=repo, + ) + return await box_service.preview_skill_zip(zip_bytes, f'{package_name}.zip', target_suffix='') + + async def reload_skills(self) -> list[dict]: + await self._reload_skills() + return await self.list_skills() + + async def scan_directory_async(self, path: str) -> dict: + box_service = self._require_box('Scanning a skill directory') + return await box_service.scan_skill_directory(path) + + async def _reload_skills(self) -> None: + skill_mgr = getattr(self.ap, 'skill_mgr', None) + reload_skills = getattr(skill_mgr, 'reload_skills', None) + if not callable(reload_skills): + return + result = reload_skills() + if inspect.isawaitable(result): + await result + + async def _download_github_asset(self, asset_url: str) -> bytes: + async with httpx.AsyncClient(follow_redirects=True, timeout=120) as client: + resp = await client.get(asset_url) + resp.raise_for_status() + return resp.content + + async def _download_github_skill_directory_as_zip( + self, asset_url: str, *, owner: str, repo: str + ) -> tuple[bytes, str, str]: + info = self._parse_github_skill_md_url(asset_url, owner=owner, repo=repo) + archive_url = f'https://codeload.github.com/{owner}/{repo}/zip/{quote(info["ref"], safe="/")}' + archive_bytes = await self._download_github_asset(archive_url) + + try: + source_archive = zipfile.ZipFile(io.BytesIO(archive_bytes), 'r') + except zipfile.BadZipFile as exc: + raise ValueError('GitHub repository archive must be a valid .zip archive') from exc + + with source_archive as source_zip: + skill_entry = self._find_github_skill_archive_entry(source_zip, info['file_path']) + try: + skill_md_content = source_zip.read(skill_entry).decode('utf-8') + except UnicodeDecodeError as exc: + raise ValueError('GitHub SKILL.md must be valid UTF-8 text') from exc + + package_name = self._resolve_github_skill_md_package_name(skill_md_content, info['package_name']) + source_skill_dir = posixpath.dirname(posixpath.normpath(skill_entry.filename)) + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as target_zip: + self._copy_github_skill_directory_to_zip(source_zip, target_zip, source_skill_dir, package_name) + return buffer.getvalue(), f'{package_name}.zip', package_name + + def _find_github_skill_archive_entry(self, archive: zipfile.ZipFile, file_path: str) -> zipfile.ZipInfo: + normalized_file_path = posixpath.normpath(file_path).lower() + for member in archive.infolist(): + if member.is_dir(): + continue + normalized_member = posixpath.normpath(member.filename) + path_parts = normalized_member.split('/', 1) + if len(path_parts) != 2: + continue + archive_relative_path = path_parts[1].lower() + if archive_relative_path == normalized_file_path: + return member + raise ValueError(f'GitHub archive does not contain requested SKILL.md: {file_path}') + + def _copy_github_skill_directory_to_zip( + self, + source_zip: zipfile.ZipFile, + target_zip: zipfile.ZipFile, + source_skill_dir: str, + package_name: str, + ) -> None: + normalized_source_dir = posixpath.normpath(source_skill_dir) + source_prefix = f'{normalized_source_dir}/' + copied_files = 0 + + for member in source_zip.infolist(): + normalized_member = posixpath.normpath(member.filename) + if normalized_member != normalized_source_dir and not normalized_member.startswith(source_prefix): + continue + + relative_path = posixpath.relpath(normalized_member, normalized_source_dir) + if relative_path in ('', '.'): + continue + if relative_path.startswith('../') or relative_path == '..' or posixpath.isabs(relative_path): + raise ValueError(f'GitHub archive contains an unsafe skill path: {member.filename}') + + target_name = f'{package_name}/{relative_path}' + if member.is_dir() and not target_name.endswith('/'): + target_name = f'{target_name}/' + target_info = zipfile.ZipInfo(target_name, date_time=member.date_time) + target_info.external_attr = member.external_attr + target_info.compress_type = zipfile.ZIP_DEFLATED + + if member.is_dir(): + target_zip.writestr(target_info, b'') + continue + + target_zip.writestr(target_info, source_zip.read(member)) + copied_files += 1 + + if copied_files == 0: + raise ValueError('GitHub skill directory is empty') + + def _uploaded_skill_target_stem(self, filename: str) -> str: + stem = os.path.splitext(os.path.basename(str(filename or '').strip()))[0] + safe_stem = ''.join(ch if ch.isalnum() or ch in ('-', '_') else '-' for ch in stem).strip('-_') + if not safe_stem: + safe_stem = 'uploaded-skill' + return safe_stem + + @staticmethod + def _is_github_skill_md_url(asset_url: str) -> bool: + parsed = urlparse(str(asset_url or '').strip()) + normalized_path = posixpath.normpath(parsed.path or '/') + return normalized_path.lower().endswith('/skill.md') + + def _parse_github_skill_md_url(self, asset_url: str, *, owner: str, repo: str) -> dict: + parsed = urlparse(str(asset_url or '').strip()) + if parsed.scheme != 'https' or not parsed.netloc: + raise ValueError('asset_url must be a valid HTTPS GitHub SKILL.md URL') + + host = parsed.netloc.lower() + path_parts = [unquote(part) for part in (parsed.path or '').split('/') if part] + if host == 'github.com': + if ( + len(path_parts) < 5 + or path_parts[0] != owner + or path_parts[1] != repo + or path_parts[2] + not in ( + 'blob', + 'raw', + ) + ): + raise ValueError('GitHub SKILL.md URL must point to the requested owner/repo blob path') + ref = path_parts[3] + file_path = '/'.join(path_parts[4:]) + elif host == 'raw.githubusercontent.com': + if len(path_parts) < 4 or path_parts[0] != owner or path_parts[1] != repo: + raise ValueError('GitHub SKILL.md URL must point to the requested owner/repo raw path') + ref = path_parts[2] + file_path = '/'.join(path_parts[3:]) + else: + raise ValueError('asset_url must point to a GitHub SKILL.md file') + + normalized_file_path = posixpath.normpath(file_path) + normalized_file_path_lower = normalized_file_path.lower() + if normalized_file_path_lower != 'skill.md' and not normalized_file_path_lower.endswith('/skill.md'): + raise ValueError('GitHub skill import requires a URL ending with SKILL.md') + + parent_dir = posixpath.basename(posixpath.dirname(normalized_file_path)) or repo + return { + 'ref': ref, + 'file_path': normalized_file_path, + 'package_name': self._uploaded_skill_target_stem(parent_dir), + } + + def _resolve_github_skill_md_package_name(self, content: str, fallback: str) -> str: + metadata, _instructions = parse_frontmatter(content) + candidate = str(metadata.get('name') or fallback or '').strip() + try: + return self._validate_skill_name(candidate) + except ValueError: + return self._validate_skill_name(fallback) + + @staticmethod + def _validate_github_asset_url(asset_url: str, *, owner: str, repo: str, release_tag: str) -> str: + parsed = urlparse(str(asset_url).strip()) + if parsed.scheme != 'https' or not parsed.netloc: + raise ValueError('asset_url must be a valid HTTPS GitHub asset URL') + + host = parsed.netloc.lower() + if host not in _GITHUB_ASSET_HOSTS: + raise ValueError('asset_url must point to a GitHub-hosted release asset or archive') + + normalized_path = posixpath.normpath(parsed.path or '/') + allowed_prefixes = [ + f'/repos/{owner}/{repo}/', + f'/{owner}/{repo}/', + ] + if not any(normalized_path.startswith(prefix) for prefix in allowed_prefixes): + raise ValueError('asset_url does not match the requested owner/repo') + + if release_tag and release_tag not in parsed.path and release_tag not in parsed.query: + raise ValueError('asset_url does not match the requested release_tag') + + return parsed.geturl() + + @staticmethod + def _validate_skill_name(name: str) -> str: + name = str(name or '').strip() + if not name: + raise ValueError('Skill name is required') + if not name.replace('-', '').replace('_', '').isalnum(): + raise ValueError('Skill name can only contain letters, numbers, hyphens and underscores') + if len(name) > 64: + raise ValueError('Skill name cannot exceed 64 characters') + return name diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py index 80198a3c5..a9185f9bc 100644 --- a/src/langbot/pkg/api/http/service/user.py +++ b/src/langbot/pkg/api/http/service/user.py @@ -20,6 +20,15 @@ class UserService: def __init__(self, ap: app.Application) -> None: self.ap = ap self._create_user_lock = asyncio.Lock() + self._password_hash_lock = asyncio.Semaphore(1) + + async def _hash_password(self, password: str) -> str: + async with self._password_hash_lock: + return await asyncio.to_thread(argon2.PasswordHasher().hash, password) + + async def _verify_password(self, hashed_password: str, password: str) -> None: + async with self._password_hash_lock: + await asyncio.to_thread(argon2.PasswordHasher().verify, hashed_password, password) async def is_initialized(self) -> bool: result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1)) @@ -28,9 +37,7 @@ class UserService: return result_list is not None and len(result_list) > 0 async def create_user(self, user_email: str, password: str) -> None: - ph = argon2.PasswordHasher() - - hashed_password = ph.hash(password) + hashed_password = await self._hash_password(password) await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password, account_type='local') @@ -69,9 +76,7 @@ class UserService: if not user_obj.password: raise ValueError('请使用 Space 账户登录') - ph = argon2.PasswordHasher() - - ph.verify(user_obj.password, password) + await self._verify_password(user_obj.password, password) return await self.generate_jwt_token(user_email) @@ -82,7 +87,7 @@ class UserService: payload = { 'user': user_email, 'iss': 'LangBot-' + constants.edition, - 'exp': datetime.datetime.now() + datetime.timedelta(seconds=jwt_expire), + 'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=jwt_expire), } return jwt.encode(payload, jwt_secret, algorithm='HS256') @@ -93,17 +98,13 @@ class UserService: return jwt.decode(token, jwt_secret, algorithms=['HS256'])['user'] async def reset_password(self, user_email: str, new_password: str) -> None: - ph = argon2.PasswordHasher() - - hashed_password = ph.hash(new_password) + hashed_password = await self._hash_password(new_password) await self.ap.persistence_mgr.execute_async( sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password) ) async def change_password(self, user_email: str, current_password: str, new_password: str) -> None: - ph = argon2.PasswordHasher() - user_obj = await self.get_user_by_email(user_email) if user_obj is None: raise ValueError('User not found') @@ -111,9 +112,9 @@ class UserService: if not user_obj.password: raise ValueError('No local password set, please set a password first') - ph.verify(user_obj.password, current_password) + await self._verify_password(user_obj.password, current_password) - hashed_password = ph.hash(new_password) + hashed_password = await self._hash_password(new_password) await self.ap.persistence_mgr.execute_async( sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password) @@ -232,7 +233,6 @@ class UserService: async def set_password(self, user_email: str, new_password: str, current_password: str | None = None) -> None: """Set or change password for a user""" - ph = argon2.PasswordHasher() user_obj = await self.get_user_by_email(user_email) if user_obj is None: @@ -243,9 +243,9 @@ class UserService: if has_password: if not current_password: raise ValueError('Current password is required') - ph.verify(user_obj.password, current_password) + await self._verify_password(user_obj.password, current_password) - hashed_password = ph.hash(new_password) + hashed_password = await self._hash_password(new_password) await self.ap.persistence_mgr.execute_async( sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password) ) diff --git a/src/langbot/pkg/api/mcp/__init__.py b/src/langbot/pkg/api/mcp/__init__.py new file mode 100644 index 000000000..dac07bf4b --- /dev/null +++ b/src/langbot/pkg/api/mcp/__init__.py @@ -0,0 +1,14 @@ +"""LangBot MCP (Model Context Protocol) server. + +This package exposes a subset of LangBot's HTTP service API as MCP tools so +that external AI agents can manage a LangBot instance through the MCP +protocol. The MCP server reuses the same API-key authentication as the HTTP +API (including the global API key from ``config.yaml``). + +See ``server.py`` for the tool surface and ``mount.py`` for the ASGI +integration with the Quart HTTP app. +""" + +from .server import LangBotMCPServer + +__all__ = ['LangBotMCPServer'] diff --git a/src/langbot/pkg/api/mcp/mount.py b/src/langbot/pkg/api/mcp/mount.py new file mode 100644 index 000000000..d113b2501 --- /dev/null +++ b/src/langbot/pkg/api/mcp/mount.py @@ -0,0 +1,112 @@ +"""ASGI integration: serve the LangBot MCP server alongside the Quart HTTP app. + +The Quart app and the MCP server are both ASGI apps. We front them with a small +dispatcher ASGI callable: + +- Requests whose path is (or is under) ``/mcp`` are authenticated with a + LangBot API key (reusing ``apikey_service.verify_api_key``, which also + accepts the global API key from ``config.yaml``) and then handed to the + FastMCP Starlette app. +- Every other request goes to the Quart app unchanged. + +The FastMCP streamable-HTTP transport requires its session manager's lifespan +to be running. Rather than rely on the dispatcher receiving ASGI lifespan +events (Quart owns those), we explicitly run the session manager in a background +task managed by LangBot's task manager. +""" + +from __future__ import annotations + +import contextlib +import typing + +from .server import LangBotMCPServer + +if typing.TYPE_CHECKING: + from ...core import app as app_module + + +# JSON-RPC-ish 401 body returned before the MCP app is reached. +_UNAUTHORIZED_BODY = b'{"error":"unauthorized","message":"A valid LangBot API key is required for MCP access."}' + + +def _extract_api_key(headers: list[tuple[bytes, bytes]]) -> str: + """Pull an API key from ASGI headers (X-API-Key or Authorization: Bearer).""" + header_map = {k.lower(): v for k, v in headers} + api_key = header_map.get(b'x-api-key', b'').decode('latin-1').strip() + if api_key: + return api_key + auth = header_map.get(b'authorization', b'').decode('latin-1').strip() + if auth.lower().startswith('bearer '): + return auth[7:].strip() + return '' + + +class MCPMount: + """Owns the MCP server and produces the dispatcher ASGI app.""" + + MCP_PATH_PREFIX = '/mcp' + + def __init__(self, ap: app_module.Application) -> None: + self.ap = ap + self.server = LangBotMCPServer(ap) + self._mcp_asgi = self.server.streamable_http_app() + self._lifespan_cm: typing.Any = None + + async def start_session_manager(self) -> None: + """Run the MCP session manager lifespan in the background. + + StreamableHTTPSessionManager.run() is a one-shot async context manager + (it may only be entered once). We keep it open for the process lifetime; + it is torn down when the event loop stops. + """ + cm = self.server.session_manager.run() + self._lifespan_cm = cm + await cm.__aenter__() + + async def stop_session_manager(self) -> None: + if self._lifespan_cm is not None: + with contextlib.suppress(Exception): + await self._lifespan_cm.__aexit__(None, None, None) + self._lifespan_cm = None + + def _is_mcp_path(self, path: str) -> bool: + return path == self.MCP_PATH_PREFIX or path.startswith(self.MCP_PATH_PREFIX + '/') + + def wrap(self, quart_asgi: typing.Callable) -> typing.Callable: + """Return a dispatcher ASGI app fronting ``quart_asgi``.""" + mcp_asgi = self._mcp_asgi + verify_api_key = self.ap.apikey_service.verify_api_key + is_mcp_path = self._is_mcp_path + + async def dispatcher(scope, receive, send): # type: ignore[no-untyped-def] + # Pass through non-HTTP scopes (lifespan, websocket) to Quart so its + # own startup/shutdown and websocket routes keep working. + if scope['type'] != 'http' or not is_mcp_path(scope.get('path', '')): + await quart_asgi(scope, receive, send) + return + + # Authenticate MCP HTTP requests with a LangBot API key. + api_key = _extract_api_key(scope.get('headers', [])) + authorized = False + if api_key: + with contextlib.suppress(Exception): + authorized = await verify_api_key(api_key) + + if not authorized: + await send( + { + 'type': 'http.response.start', + 'status': 401, + 'headers': [ + (b'content-type', b'application/json'), + (b'www-authenticate', b'Bearer'), + ], + } + ) + await send({'type': 'http.response.body', 'body': _UNAUTHORIZED_BODY}) + return + + await mcp_asgi(scope, receive, send) + + return dispatcher diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py new file mode 100644 index 000000000..95630bbaf --- /dev/null +++ b/src/langbot/pkg/api/mcp/server.py @@ -0,0 +1,204 @@ +"""LangBot MCP server definition. + +Wraps a curated subset of LangBot's HTTP service API as MCP tools. Tools call +the existing service layer directly (not the HTTP API over the network), so the +MCP surface stays aligned with the API by construction. + +IMPORTANT: when you add, remove, or change an HTTP API endpoint that should be +agent-accessible, update the corresponding MCP tool here AND the skills under +``skills/`` (see AGENTS.md). The MCP tool surface and the API must stay aligned. + +Scope (first version): core read operations plus the most common writes for +bots, pipelines, LLM/embedding models, knowledge bases, MCP servers, skills, +and read-only system info. This intentionally does NOT expose every one of the +~25 HTTP route groups — that keeps the agent surface small, safe, and +maintainable. Extend deliberately. +""" + +from __future__ import annotations + +import json +import typing + +from mcp.server.fastmcp import FastMCP + +if typing.TYPE_CHECKING: + from ...core import app as app_module + + +INSTRUCTIONS = """\ +This MCP server manages a LangBot instance. LangBot is an LLM-native instant +messaging bot platform. Use these tools to inspect and manage bots, pipelines, +models, knowledge bases, MCP servers, and skills. + +Authentication uses a LangBot API key (web-UI-created `lbk_...` key or the +global API key from config.yaml), passed as the `X-API-Key` header or +`Authorization: Bearer `. + +Prefer the `list_*` / `get_*` tools to discover resources before mutating. All +identifiers are UUIDs unless noted. Mutating tools take JSON objects matching +the same shape as the LangBot HTTP API request bodies. +""" + + +def _dump(value: typing.Any) -> str: + """Serialize a tool result to a compact JSON string for the agent.""" + return json.dumps(value, ensure_ascii=False, default=str) + + +class LangBotMCPServer: + """Builds and owns the FastMCP instance for LangBot.""" + + def __init__(self, ap: app_module.Application) -> None: + self.ap = ap + # Stateless HTTP so the server does not need sticky sessions behind a + # load balancer; json_response keeps responses simple (no SSE stream + # required for unary tool calls). + self.mcp = FastMCP( + name='LangBot', + instructions=INSTRUCTIONS, + stateless_http=True, + json_response=True, + ) + self._register_tools() + + # ------------------------------------------------------------------ # + # Tool registration + # ------------------------------------------------------------------ # + def _register_tools(self) -> None: + ap = self.ap + mcp = self.mcp + + # ----- System (read-only) -------------------------------------- # + @mcp.tool(description='Get basic LangBot system/runtime information (version, edition).') + async def get_system_info() -> str: + version = None + try: + version = ap.ver_mgr.get_current_version() + except Exception: + pass + data = { + 'version': version, + 'edition': ap.instance_config.data.get('system', {}).get('edition'), + 'instance_id': ap.instance_config.data.get('system', {}).get('instance_id'), + } + return _dump(data) + + # ----- Bots ---------------------------------------------------- # + @mcp.tool(description='List all messaging-platform bots. Secrets are redacted.') + async def list_bots() -> str: + return _dump(await ap.bot_service.get_bots(include_secret=False)) + + @mcp.tool(description='Get a single bot by its UUID. Secrets are redacted.') + async def get_bot(bot_uuid: str) -> str: + return _dump(await ap.bot_service.get_bot(bot_uuid, include_secret=False)) + + @mcp.tool( + description=( + 'Create a bot. `bot_data` is a JSON object matching the LangBot ' + 'POST /api/v1/platform/bots body (e.g. name, adapter, config). ' + 'Returns the new bot UUID.' + ) + ) + async def create_bot(bot_data: dict) -> str: + return _dump({'uuid': await ap.bot_service.create_bot(bot_data)}) + + @mcp.tool(description='Update a bot by UUID. `bot_data` matches the PUT bot body.') + async def update_bot(bot_uuid: str, bot_data: dict) -> str: + await ap.bot_service.update_bot(bot_uuid, bot_data) + return _dump({'ok': True}) + + @mcp.tool(description='Delete a bot by UUID.') + async def delete_bot(bot_uuid: str) -> str: + await ap.bot_service.delete_bot(bot_uuid) + return _dump({'ok': True}) + + # ----- Pipelines ----------------------------------------------- # + @mcp.tool(description='List all pipelines.') + async def list_pipelines() -> str: + return _dump(await ap.pipeline_service.get_pipelines()) + + @mcp.tool(description='Get a single pipeline by UUID.') + async def get_pipeline(pipeline_uuid: str) -> str: + return _dump(await ap.pipeline_service.get_pipeline(pipeline_uuid)) + + @mcp.tool( + description=( + 'Create a pipeline. `pipeline_data` matches the LangBot POST ' + '/api/v1/pipelines body. Returns the new pipeline UUID.' + ) + ) + async def create_pipeline(pipeline_data: dict) -> str: + return _dump({'uuid': await ap.pipeline_service.create_pipeline(pipeline_data)}) + + @mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.') + async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str: + await ap.pipeline_service.update_pipeline(pipeline_uuid, pipeline_data) + return _dump({'ok': True}) + + @mcp.tool(description='Delete a pipeline by UUID.') + async def delete_pipeline(pipeline_uuid: str) -> str: + await ap.pipeline_service.delete_pipeline(pipeline_uuid) + return _dump({'ok': True}) + + # ----- Models -------------------------------------------------- # + @mcp.tool(description='List all configured LLM models. Secrets are redacted.') + async def list_llm_models() -> str: + return _dump(await ap.llm_model_service.get_llm_models(include_secret=False)) + + @mcp.tool(description='Get a single LLM model by UUID.') + async def get_llm_model(model_uuid: str) -> str: + return _dump(await ap.llm_model_service.get_llm_model(model_uuid)) + + @mcp.tool(description='List all configured embedding models.') + async def list_embedding_models() -> str: + return _dump(await ap.embedding_models_service.get_embedding_models()) + + @mcp.tool(description='List all model providers (OpenAI-compatible, Anthropic, etc.).') + async def list_model_providers() -> str: + return _dump(await ap.provider_service.get_providers()) + + # ----- Knowledge bases ----------------------------------------- # + @mcp.tool(description='List all knowledge bases (RAG).') + async def list_knowledge_bases() -> str: + return _dump(await ap.knowledge_service.get_knowledge_bases()) + + @mcp.tool(description='Get a single knowledge base by UUID.') + async def get_knowledge_base(kb_uuid: str) -> str: + return _dump(await ap.knowledge_service.get_knowledge_base(kb_uuid)) + + @mcp.tool( + description=('Retrieve (semantic search) from a knowledge base. Returns the matched chunks for `query`.') + ) + async def retrieve_knowledge_base(kb_uuid: str, query: str) -> str: + return _dump(await ap.knowledge_service.retrieve_knowledge_base(kb_uuid, query)) + + # ----- MCP servers (LangBot as MCP client) --------------------- # + @mcp.tool( + description=( + 'List external MCP servers registered in LangBot (the servers LangBot itself connects to as a client).' + ) + ) + async def list_mcp_servers() -> str: + return _dump(await ap.mcp_service.get_mcp_servers()) + + # ----- Skills -------------------------------------------------- # + @mcp.tool(description='List installed skills.') + async def list_skills() -> str: + return _dump(await ap.skill_service.list_skills()) + + @mcp.tool(description='Get a single skill by name.') + async def get_skill(skill_name: str) -> str: + return _dump(await ap.skill_service.get_skill(skill_name)) + + # ------------------------------------------------------------------ # + # ASGI app + # ------------------------------------------------------------------ # + def streamable_http_app(self): # type: ignore[no-untyped-def] + """Return the Starlette ASGI app serving MCP over streamable HTTP at /mcp.""" + return self.mcp.streamable_http_app() + + @property + def session_manager(self): # type: ignore[no-untyped-def] + """Expose the session manager so its lifespan can be run by the host.""" + return self.mcp.session_manager diff --git a/src/langbot/pkg/box/__init__.py b/src/langbot/pkg/box/__init__.py new file mode 100644 index 000000000..de6394177 --- /dev/null +++ b/src/langbot/pkg/box/__init__.py @@ -0,0 +1,5 @@ +"""LangBot Box runtime package.""" + +from .workspace import BoxWorkspaceSession + +__all__ = ['BoxWorkspaceSession'] diff --git a/src/langbot/pkg/box/connector.py b/src/langbot/pkg/box/connector.py new file mode 100644 index 000000000..2257910d1 --- /dev/null +++ b/src/langbot/pkg/box/connector.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +import asyncio +import json +import os +import sys +import typing +from typing import TYPE_CHECKING +from urllib.parse import urlparse + +from langbot_plugin.entities.io.actions.enums import CommonAction +from langbot_plugin.runtime.io.handler import Handler +from langbot_plugin.runtime.io.connection import Connection + +from langbot_plugin.box.client import ActionRPCBoxClient +from langbot_plugin.box.errors import BoxRuntimeUnavailableError +from langbot_plugin.box.actions import LangBotToBoxAction + +from ..utils import platform +from ..utils.managed_runtime import ManagedRuntimeConnector + +if TYPE_CHECKING: + from ..core import app as core_app + + +# Default Docker Compose service name for the standalone Box container. +_DOCKER_BOX_HOST = 'langbot_box' +_DEFAULT_PORT = 5410 + +_HEARTBEAT_INTERVAL_SEC = 20 + +# Top-level keys under ``box`` that are LangBot-internal and should not be +# forwarded to the Box runtime. +_INTERNAL_BOX_CONFIG_KEYS = frozenset({'runtime'}) + + +def _get_box_config(ap) -> dict: + """Return the 'box' section from instance config. + + Environment-variable overrides are handled uniformly by + ``LoadConfigStage._apply_env_overrides_to_config`` using the + ``SECTION__SUBSECTION__KEY`` convention (e.g. ``BOX__LOCAL__HOST_ROOT``, + ``BOX__LOCAL__ALLOWED_MOUNT_ROOTS="/a,/b"``) before this is read, so no + box-specific env parsing is needed here. + """ + instance_config = getattr(ap, 'instance_config', None) + config_data = getattr(instance_config, 'data', {}) if instance_config is not None else {} + return dict(config_data.get('box', {}) or {}) + + +def _get_runtime_endpoint(box_cfg: dict) -> str: + runtime_cfg = box_cfg.get('runtime') or {} + return str(runtime_cfg.get('endpoint', '')).strip() + + +def _filter_config_for_runtime(box_cfg: dict) -> dict: + return {k: v for k, v in box_cfg.items() if k not in _INTERNAL_BOX_CONFIG_KEYS} + + +def resolve_box_ws_relay_url(ap: core_app.Application) -> str: + """Derive the WS relay base URL used for managed-process attach. + + The WS relay serves the ``/v1/sessions/{id}/managed-process/ws`` endpoint + on the *relay* port (default 5410). + """ + box_cfg = _get_box_config(ap) + + # Explicit runtime endpoint takes precedence. The config value is a base + # URL; endpoint-specific paths are appended by the SDK client. + endpoint = _get_runtime_endpoint(box_cfg) + if endpoint: + parsed = urlparse(endpoint) + scheme = parsed.scheme or 'ws' + if scheme == 'ws': + scheme = 'http' + elif scheme == 'wss': + scheme = 'https' + host = parsed.hostname or '127.0.0.1' + port = parsed.port or _DEFAULT_PORT + return f'{scheme}://{host}:{port}' + + # In Docker, relay lives on the box runtime container. + if platform.get_platform() == 'docker': + return f'http://{_DOCKER_BOX_HOST}:{_DEFAULT_PORT}' + + return f'http://127.0.0.1:{_DEFAULT_PORT}' + + +class BoxRuntimeConnector(ManagedRuntimeConnector): + """Connect to the Box runtime via action RPC. + + Transport decision (mirrors Plugin runtime logic): + 1. Docker / --standalone-box / explicit runtime.endpoint -> WebSocket to external Box process + 2. Windows (non-Docker) -> subprocess + WebSocket (Windows lacks async stdio pipe) + 3. Unix / macOS -> subprocess + stdio pipe + """ + + def __init__( + self, + ap: core_app.Application, + runtime_disconnect_callback: typing.Callable[ + ['BoxRuntimeConnector'], typing.Coroutine[typing.Any, typing.Any, None] + ] + | None = None, + ): + super().__init__(ap) + self.runtime_disconnect_callback = runtime_disconnect_callback + self.configured_runtime_endpoint = self._load_configured_runtime_endpoint() + self.ws_relay_base_url = resolve_box_ws_relay_url(ap) + self.client = ActionRPCBoxClient(logger=ap.logger) + + self._handler: Handler | None = None + self._handler_task: asyncio.Task | None = None + self._ctrl_task: asyncio.Task | None = None + self._heartbeat_task: asyncio.Task | None = None + + # Parse the relay URL once for reuse. + parsed = urlparse(self.ws_relay_base_url) + self._relay_host = parsed.hostname or '127.0.0.1' + self._relay_port = parsed.port or _DEFAULT_PORT + self._filtered_box_config = _filter_config_for_runtime(_get_box_config(ap)) + + def uses_websocket(self) -> bool: + """Whether the connector should use WebSocket to reach the Box runtime. + + True when: + - Running inside Docker (Box runtime is a separate container) + - The ``--standalone-box`` CLI flag was passed + - An explicit ``runtime.endpoint`` was configured + + When this is True the Box runtime lives in a separate process with its + own filesystem view (container, pod sidecar, or remote host), so paths + it reports (e.g. skill ``package_root``) are NOT resolvable on the + LangBot side. When False, Box runs as a stdio child process that shares + LangBot's filesystem. + """ + return bool( + self.configured_runtime_endpoint + or platform.get_platform() == 'docker' + or platform.use_websocket_to_connect_box_runtime() + ) + + # Backwards-compatible private alias. + def _uses_websocket(self) -> bool: + return self.uses_websocket() + + async def initialize(self) -> None: + if self._uses_websocket(): + if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint: + await self._start_subprocess_then_ws() + else: + await self._connect_remote_ws() + else: + await self._start_local_stdio() + + # Start heartbeat after successful connection + if self._heartbeat_task is None: + self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + + # -- heartbeat ----------------------------------------------------------- + + async def _heartbeat_loop(self) -> None: + """Periodically ping the Box runtime to detect silent disconnections.""" + while True: + await asyncio.sleep(_HEARTBEAT_INTERVAL_SEC) + try: + await self.ping() + self.ap.logger.debug('Heartbeat to Box runtime success.') + except Exception as e: + self.ap.logger.debug(f'Failed to heartbeat to Box runtime: {e}') + + async def ping(self) -> None: + if self._handler is None: + raise BoxRuntimeUnavailableError('Box runtime is not connected') + await self._handler.call_action(CommonAction.PING, {}) + + # -- transport paths ----------------------------------------------------- + + async def _start_local_stdio(self) -> None: + """Launch box server as subprocess and connect via stdio (Unix/macOS).""" + from langbot_plugin.runtime.io.controllers.stdio.client import StdioClientController + + self.ap.logger.info('Use stdio to connect to box runtime') + python_path = sys.executable + env = os.environ.copy() + if self._filtered_box_config: + env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config) + + connected = asyncio.Event() + connect_error: list[Exception] = [] + + ctrl = StdioClientController( + command=python_path, + # Launched through the same CLI entry point as the plugin runtime + # (cli.__init__ ); `-s` selects the stdio transport, + # mirroring `rt -s`. + args=['-m', 'langbot_plugin.cli.__init__', 'box', '-s', '--ws-control-port', str(self._relay_port)], + env=env, + ) + self._ctrl_task = asyncio.create_task( + ctrl.run(self._make_connection_callback('stdio', connected, connect_error)) + ) + + try: + await asyncio.wait_for(connected.wait(), timeout=30.0) + except asyncio.TimeoutError: + raise BoxRuntimeUnavailableError('box runtime subprocess did not connect in time') + + if connect_error: + raise BoxRuntimeUnavailableError(f'box runtime connection failed: {connect_error[0]}') + + self._subprocess = ctrl.process + + async def _start_subprocess_then_ws(self) -> None: + """Launch box server as detached subprocess, then connect via WS (Windows).""" + self.ap.logger.info('(windows) Use cmd to launch box runtime and communicate via ws') + + env = os.environ.copy() + if self._filtered_box_config: + env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config) + + python_path = sys.executable + # Launched through the same CLI entry point as the plugin runtime + # (cli.__init__ ); no flag => WebSocket transport. + self.runtime_subprocess = await asyncio.create_subprocess_exec( + python_path, + '-m', + 'langbot_plugin.cli.__init__', + 'box', + '--ws-control-port', + str(self._relay_port), + env=env, + ) + self.runtime_subprocess_task = asyncio.create_task(self.runtime_subprocess.wait()) + + ws_url = f'ws://localhost:{self._relay_port}/rpc/ws' + await self._connect_ws(ws_url, '(windows) WebSocket') + + async def _connect_remote_ws(self) -> None: + """Connect to a remote (or Docker) box server via WebSocket.""" + ws_url = self._resolve_rpc_ws_url() + self.ap.logger.info(f'Use WebSocket to connect to box runtime ({ws_url})') + await self._connect_ws(ws_url, 'WebSocket') + + # -- helpers ------------------------------------------------------------- + + def _resolve_rpc_ws_url(self) -> str: + """Determine the action-RPC WebSocket URL. + + All endpoints share a single port; action RPC is at ``/rpc/ws``. + """ + if self.configured_runtime_endpoint: + base = self.configured_runtime_endpoint.rstrip('/') + parsed = urlparse(base) + scheme = parsed.scheme or 'ws' + if scheme in ('http', 'https'): + scheme = 'wss' if scheme == 'https' else 'ws' + host = parsed.hostname or '127.0.0.1' + port = parsed.port or _DEFAULT_PORT + return f'{scheme}://{host}:{port}/rpc/ws' + + if platform.get_platform() == 'docker': + return f'ws://{_DOCKER_BOX_HOST}:{_DEFAULT_PORT}/rpc/ws' + + return f'ws://localhost:{self._relay_port}/rpc/ws' + + async def _connect_ws(self, ws_url: str, transport_name: str) -> None: + """Shared WebSocket connection procedure.""" + from langbot_plugin.runtime.io.controllers.ws.client import WebSocketClientController + + connected = asyncio.Event() + connect_error: list[Exception] = [] + + async def on_connect_failed(ctrl, exc): + if exc is not None: + self.ap.logger.error(f'Failed to connect to Box runtime ({ws_url}): {exc}') + else: + self.ap.logger.error(f'Failed to connect to Box runtime ({ws_url}), trying to reconnect...') + connect_error.append(exc or BoxRuntimeUnavailableError('ws connection failed')) + connected.set() + if self.runtime_disconnect_callback is not None: + await self.runtime_disconnect_callback(self) + + ctrl = WebSocketClientController(ws_url=ws_url, make_connection_failed_callback=on_connect_failed) + self._ctrl_task = asyncio.create_task( + ctrl.run(self._make_connection_callback(transport_name, connected, connect_error)) + ) + + try: + await asyncio.wait_for(connected.wait(), timeout=30.0) + except asyncio.TimeoutError: + raise BoxRuntimeUnavailableError(f'box runtime ws connection timed out ({ws_url})') + + if connect_error: + raise BoxRuntimeUnavailableError(f'box runtime connection failed: {connect_error[0]}') + + def _make_connection_callback( + self, + transport_name: str, + connected: asyncio.Event, + connect_error: list[Exception], + ): + async def new_connection_callback(connection: Connection) -> None: + handler = Handler(connection) + self._handler = handler + self.client.set_handler(handler) + self._handler_task = asyncio.create_task(handler.run()) + try: + await handler.call_action(CommonAction.PING, {}) + if self._filtered_box_config: + await handler.call_action(LangBotToBoxAction.INIT, self._filtered_box_config) + self.ap.logger.debug('Sent box configuration to Box runtime via INIT.') + self.ap.logger.info(f'Connected to Box runtime via {transport_name}.') + connected.set() + await self._handler_task + except Exception as exc: + if not connected.is_set(): + connect_error.append(exc) + connected.set() + return + + # If we reach here, handler.run() returned normally (connection + # closed) or raised after the initial handshake succeeded. + # Either way, treat it as a disconnect. + if connected.is_set(): + if self._uses_websocket(): + self.ap.logger.error('Disconnected from Box runtime, trying to reconnect...') + if self.runtime_disconnect_callback is not None: + await self.runtime_disconnect_callback(self) + else: + self.ap.logger.error( + 'Disconnected from Box runtime via stdio. ' + 'Cannot automatically reconnect — please restart LangBot.' + ) + + return new_connection_callback + + # -- lifecycle ----------------------------------------------------------- + + def dispose(self) -> None: + if self._heartbeat_task is not None: + self._heartbeat_task.cancel() + self._heartbeat_task = None + + if self._handler_task is not None: + self._handler_task.cancel() + self._handler_task = None + + if self._ctrl_task is not None: + self._ctrl_task.cancel() + self._ctrl_task = None + + # stdio-managed subprocess (stored as self._subprocess by _start_local_stdio) + if hasattr(self, '_subprocess') and self._subprocess is not None and self._subprocess.returncode is None: + self.ap.logger.info('Terminating managed box runtime process...') + self._subprocess.terminate() + + # Subprocess launched by ManagedRuntimeConnector._start_runtime_subprocess (Windows path) + self._dispose_subprocess() + + # -- config helpers ------------------------------------------------------ + + def _load_configured_runtime_endpoint(self) -> str: + return _get_runtime_endpoint(_get_box_config(self.ap)) diff --git a/src/langbot/pkg/box/policy.py b/src/langbot/pkg/box/policy.py new file mode 100644 index 000000000..15f4c45c9 --- /dev/null +++ b/src/langbot/pkg/box/policy.py @@ -0,0 +1,98 @@ +"""Three-layer security policy for LangBot Box. + +The design separates concerns into three independent layers, aligned with +OpenCode / OpenClaw patterns: + +1. **SandboxPolicy** – *where* tools run (host vs sandbox). +2. **ToolPolicy** – *which* tools are allowed (allow/deny lists). +3. **ElevatedPolicy** – *whether* a single exec call may temporarily + escape the default sandbox boundary. + +These three layers are orthogonal: +- ToolPolicy is a hard boundary; ``elevated`` cannot bypass a denied tool. +- SandboxPolicy decides the default execution location. +- ElevatedPolicy only affects ``exec`` and only when the framework allows it. +""" + +from __future__ import annotations + +import enum +from typing import Sequence + + +# ── Layer 1: Sandbox Policy ────────────────────────────────────────── + + +class SandboxMode(str, enum.Enum): + """Determines when agent execution is routed through the sandbox.""" + + OFF = 'off' + """Sandbox disabled; all exec runs on the host.""" + + NON_DEFAULT = 'non_default' + """Only non-default sessions are sandboxed (e.g. sub-agents, MCP).""" + + ALL = 'all' + """Every agent exec call is routed through the sandbox.""" + + +class SandboxPolicy: + """Decides whether a given execution context should use the sandbox.""" + + def __init__(self, mode: SandboxMode = SandboxMode.ALL): + self.mode = mode + + def should_sandbox(self, *, is_default_session: bool = True) -> bool: + if self.mode == SandboxMode.OFF: + return False + if self.mode == SandboxMode.ALL: + return True + # NON_DEFAULT: sandbox everything except the default session + return not is_default_session + + +# ── Layer 2: Tool Policy ───────────────────────────────────────────── + + +class ToolPolicy: + """Controls which tools are available to the current agent/session. + + Rules: + - ``deny`` always takes precedence over ``allow``. + - An empty ``allow`` list means "all tools allowed" (no allowlist filter). + - ``elevated`` cannot bypass a denied tool. + """ + + def __init__( + self, + allow: Sequence[str] = (), + deny: Sequence[str] = (), + ): + self._allow: frozenset[str] = frozenset(allow) + self._deny: frozenset[str] = frozenset(deny) + + def is_tool_allowed(self, tool_name: str) -> bool: + if tool_name in self._deny: + return False + if self._allow and tool_name not in self._allow: + return False + return True + + +# ── Layer 3: Elevated Policy ───────────────────────────────────────── + + +class ElevatedPolicy: + """Controls whether ``exec`` may request temporary privilege escalation. + + ``elevated`` only applies to the ``exec`` tool. It means "run this + command outside the default sandbox boundary" (e.g. with network, or + on the host). The framework decides whether to honor the request. + """ + + def __init__(self, *, allow_elevated: bool = False, require_approval: bool = True): + self.allow_elevated = allow_elevated + self.require_approval = require_approval + + def is_elevation_permitted(self) -> bool: + return self.allow_elevated diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py new file mode 100644 index 000000000..ca30eb930 --- /dev/null +++ b/src/langbot/pkg/box/service.py @@ -0,0 +1,1388 @@ +from __future__ import annotations + +import asyncio +import collections +import datetime as _dt +import enum +import json +import os +from typing import TYPE_CHECKING + +import pydantic + +from langbot_plugin.box.client import BoxRuntimeClient +from .connector import BoxRuntimeConnector, _get_box_config +from ..telemetry import features as telemetry_features +from langbot_plugin.box.errors import BoxError, BoxValidationError +from langbot_plugin.box.models import ( + BUILTIN_PROFILES, + BoxExecutionResult, + BoxManagedProcessInfo, + BoxManagedProcessSpec, + BoxProfile, + BoxSpec, +) + +_INT_ADAPTER = pydantic.TypeAdapter(int) +_UTC = _dt.timezone.utc +_MAX_RECENT_ERRORS = 50 +_MIB = 1024 * 1024 + + +def _is_path_under(path: str, root: str) -> bool: + """Check whether *path* equals *root* or is a child of *root*.""" + return path == root or path.startswith(f'{root}{os.sep}') + + +if TYPE_CHECKING: + from ..core import app as core_app + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +class BoxService: + def __init__( + self, + ap: core_app.Application, + client: BoxRuntimeClient | None = None, + output_limit_chars: int = 4000, + ): + self.ap = ap + self._enabled = self._load_enabled() + self._runtime_connector: BoxRuntimeConnector | None = None + if client is None: + # Always construct a connector — its __init__ is side-effect free + # (no I/O, no subprocess). When ``box.enabled = false`` we simply + # skip ``connector.initialize()`` so no connection is attempted. + self._runtime_connector = BoxRuntimeConnector(ap, runtime_disconnect_callback=self._on_runtime_disconnect) + client = self._runtime_connector.client + self.client = client + self.output_limit_chars = output_limit_chars + self.host_root = self._load_host_root() + self.allowed_mount_roots = self._load_allowed_mount_roots() + self.default_workspace = self._load_default_workspace() + self.profile = self._load_profile() + self.custom_image = self._load_custom_image() + self.workspace_quota_mb = self._load_workspace_quota_mb() + self._recent_errors: collections.deque[dict] = collections.deque(maxlen=_MAX_RECENT_ERRORS) + self._shutdown_task = None + self._available = False + self._connector_error: str = '' + self._reconnecting = False + # Optional explicit override for shares_filesystem_with_box. None means + # "derive from the connector transport". Set by tests / embedders that + # know the real LangBot<->Box filesystem topology. + self._shares_filesystem_with_box_override: bool | None = None + + @property + def enabled(self) -> bool: + """Whether Box is enabled in config. False means the operator has + deliberately turned the sandbox off via ``box.enabled = false``. + Disabled and "enabled but unavailable" are reported as the same + ``available = False`` to consumers, but distinguished in get_status.""" + return self._enabled + + async def initialize(self): + if not self._enabled: + # Disabled by config: do NOT connect to a remote runtime, do NOT + # fork a stdio subprocess. Every consumer of box_service should + # gate on ``available`` and degrade gracefully. + self._available = False + self._connector_error = 'Box runtime is disabled in config (box.enabled = false)' + self.ap.logger.info( + 'Box runtime disabled by config; sandbox features (exec/read/write/edit, ' + 'skill add/edit, stdio MCP) will be unavailable.' + ) + return + try: + if self._runtime_connector is not None: + await self._runtime_connector.initialize() + else: + await self.client.initialize() + self._ensure_default_workspace() + self._available = True + self._connector_error = '' + self.ap.logger.info( + f'LangBot Box runtime initialized: profile={self.profile.name} ' + f'default_workspace={self.default_workspace or "(none)"}' + ) + await self._purge_attachment_dirs() + except Exception as exc: + self.ap.logger.warning(f'LangBot Box runtime unavailable, sandbox features disabled: {exc}') + self._available = False + self._connector_error = str(exc) + + async def _on_runtime_disconnect(self, connector: BoxRuntimeConnector) -> None: + """Called by the connector when the Box runtime connection drops. + + Spawns a background reconnection loop so the caller is not blocked. + Skipped entirely when Box is disabled by config — that path should + never have connected in the first place. + """ + if not self._enabled: + return + if self._reconnecting: + return # Another reconnect loop is already running + self._reconnecting = True + self._available = False + self._connector_error = 'Disconnected from Box runtime' + self.ap.logger.warning('Box runtime disconnected, sandbox features temporarily disabled.') + asyncio.create_task(self._reconnect_loop(connector)) + + async def _reconnect_loop(self, connector: BoxRuntimeConnector) -> None: + """Retry reconnection with exponential backoff (3s → 60s max).""" + delay = 3 + max_delay = 60 + try: + while True: + self.ap.logger.info(f'Attempting to reconnect to Box runtime in {delay}s...') + await asyncio.sleep(delay) + try: + connector.dispose() + await connector.initialize() + self._available = True + self._connector_error = '' + self.ap.logger.info('Box runtime reconnected, sandbox features restored.') + return + except Exception as exc: + self._connector_error = str(exc) + self.ap.logger.warning(f'Box runtime reconnection failed: {exc}') + delay = min(delay * 2, max_delay) + finally: + self._reconnecting = False + + @property + def available(self) -> bool: + return self._available + + @property + def shares_filesystem_with_box(self) -> bool: + """Whether LangBot and the Box runtime share a filesystem view. + + This is True only when Box runs as a local stdio child process of + LangBot (same container/host). In that case paths the Box runtime + reports — notably skill ``package_root`` — resolve identically on the + LangBot side, so LangBot may validate them against its own filesystem. + + It is False for every separated deployment (Docker Compose, k8s + sidecar, ``--standalone-box``, or an explicit ``runtime.endpoint``), + where the Box runtime owns its own filesystem and LangBot must trust + the paths it reports rather than checking them locally. + + When Box is wired up with an injected client (tests, custom embeds) + there is no connector to introspect; we conservatively report False so + LangBot never wrongly drops Box-reported skills. An explicit override + can be set via ``_shares_filesystem_with_box`` (used by tests and any + embedder that knows the real topology). + """ + if self._shares_filesystem_with_box_override is not None: + return self._shares_filesystem_with_box_override + if self._runtime_connector is None: + return False + return not self._runtime_connector.uses_websocket() + + async def execute_spec_payload( + self, + spec_payload: dict, + query: pipeline_query.Query, + *, + skip_host_mount_validation: bool = False, + ) -> dict: + if not self._available: + raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.') + try: + spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation) + except BoxError as exc: + self._record_error(exc, query) + raise + self.ap.logger.info( + 'LangBot Box request: ' + f'query_id={query.query_id} ' + f'spec={json.dumps(self._summarize_spec(spec), ensure_ascii=False)}' + ) + try: + await self._enforce_workspace_quota(spec, phase='before execution') + except BoxError as exc: + self._record_error(exc, query) + raise + try: + result = await self.client.execute(spec) + except BoxError as exc: + self._record_error(exc, query) + raise + try: + await self._enforce_workspace_quota(spec, phase='after execution') + except BoxError as exc: + await self._cleanup_exceeded_session(spec) + self._record_error(exc, query) + raise + self.ap.logger.info( + 'LangBot Box result: ' + f'query_id={query.query_id} ' + f'summary={json.dumps(self._summarize_result(result), ensure_ascii=False)}' + ) + telemetry_features.increment(query, 'sandbox', 'execs') + return self._serialize_result(result) + + def resolve_box_session_id(self, query: pipeline_query.Query) -> str: + """Resolve the Box session_id from the pipeline's template and query variables. + + When ``system.limitation.force_box_session_id_template`` is set to a + non-empty value, that template overrides whatever the pipeline + configured. This is the authoritative SaaS guard: it runs on every + ``exec`` call, so a tenant cannot escape a single shared sandbox even + by editing the pipeline config directly through the API (which only + gates the web UI). + """ + forced_template = self._forced_box_session_id_template() + if forced_template: + template = forced_template + else: + template = ( + (query.pipeline_config or {}) + .get('ai', {}) + .get('local-agent', {}) + .get('box-session-id-template', '{launcher_type}_{launcher_id}') + ) + variables = dict(query.variables or {}) + launcher_type = getattr(query, 'launcher_type', None) + if hasattr(launcher_type, 'value'): + launcher_type = launcher_type.value + launcher_id = getattr(query, 'launcher_id', None) + sender_id = getattr(query, 'sender_id', None) + query_id = getattr(query, 'query_id', None) + + variables.setdefault('query_id', str(query_id or 'unknown')) + variables.setdefault('launcher_type', str(launcher_type or 'query')) + variables.setdefault('launcher_id', str(launcher_id or query_id or 'unknown')) + variables.setdefault('sender_id', str(sender_id or launcher_id or query_id or 'unknown')) + variables.setdefault('global', 'global') + return template.format_map(collections.defaultdict(lambda: 'unknown', variables)) + + def build_skill_extra_mounts(self, query: pipeline_query.Query) -> list[dict]: + """Build extra_mounts entries for all pipeline-bound skills. + + This ensures that when a container is first created it already has + all skill packages mounted, regardless of which skill is currently + activated. + + Path validation is filesystem-topology dependent. When LangBot and the + Box runtime share a filesystem (local stdio mode), a skill whose + ``package_root`` is missing or no longer a directory is skipped with a + warning instead of being passed through to the backend. Without that + guard the three backends behave inconsistently on a stale mount: nsjail + refuses to start the sandbox (failing every exec in the session), + Docker silently auto-creates a root-owned empty directory on the host, + and E2B silently skips the upload — none of which surfaces an + actionable error. + + When Box runs as a separate process (Docker Compose, k8s sidecar, + ``--standalone-box``, or a remote ``runtime.endpoint``), the + ``package_root`` reported by ``list_skills`` is the Box runtime's own + filesystem path and is NOT resolvable on the LangBot side. Validating + it locally would wrongly drop every skill, so LangBot trusts the path + and lets the Box runtime resolve it. The Box runtime only ever reports + skills it discovered on its own filesystem, so the path is valid there + by construction. + """ + skill_mgr = getattr(self.ap, 'skill_mgr', None) + if skill_mgr is None: + return [] + + from ..provider.tools.loaders import skill as skill_loader + + validate_locally = self.shares_filesystem_with_box + + visible_skills = skill_loader.get_visible_skills(self.ap, query) + mounts: list[dict] = [] + for skill_name, skill_data in visible_skills.items(): + package_root = str(skill_data.get('package_root', '') or '').strip() + if not package_root: + continue + if validate_locally and not os.path.isdir(package_root): + self.ap.logger.warning( + f'Skill "{skill_name}" package_root missing on filesystem ' + f'({package_root}); skipping mount to prevent sandbox failures. ' + f'The skill cache may be stale — consider reloading skills.' + ) + continue + mounts.append( + { + 'host_path': package_root, + 'mount_path': f'/workspace/.skills/{skill_name}', + 'mode': 'rw', + } + ) + return mounts + + async def execute_tool(self, parameters: dict, query: pipeline_query.Query) -> dict: + """Execute an agent-facing ``exec`` tool call. + + Translates the agent-facing ``command`` field to the internal + ``BoxSpec.cmd`` field and injects the session id from the query. + """ + spec_payload: dict = {'cmd': parameters['command']} + + # Pass through allowed agent-facing fields + for key in ('workdir', 'timeout_sec', 'env'): + if key in parameters: + spec_payload[key] = parameters[key] + + # Inject context the agent must not control + spec_payload.setdefault('session_id', self.resolve_box_session_id(query)) + + # Mount all pipeline-bound skills so they are available in the container + if 'extra_mounts' not in spec_payload: + spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query) + + return await self.execute_spec_payload(spec_payload, query) + + # ── Attachment passthrough (inbound / outbound) ────────────────── + # + # IM/webchat attachments (images, voices, files) reach the LLM as + # multimodal content, but historically never landed on the sandbox + # filesystem, so the agent's exec/read/write tools could not operate on + # them. Conversely, files the agent produced inside the sandbox were + # never surfaced back to the user. These two helpers close both gaps: + # + # inbound : message_chain attachments -> /workspace/inbox// + # outbound : /workspace/outbox// -> reply MessageChain + # + # Transfer prefers DIRECT HOST FILESYSTEM access to the bind-mounted + # workspace (default_workspace on the host maps to /workspace inside the + # container), which has no size limit. This covers the local docker / + # nsjail / stdio backends. For backends where the workspace is NOT visible + # on the LangBot host (E2B, an external remote runtime.endpoint), it falls + # back to a base64-through-exec round-trip. The exec channel can only move + # small files reliably — the docker backend passes the command as a single + # argv (ARG_MAX) and exec stdout is truncated by output_limit_chars — so + # the host path is strongly preferred and used whenever available. + + INBOX_MOUNT_DIR = '/workspace/inbox' + OUTBOX_MOUNT_DIR = '/workspace/outbox' + INBOX_SUBDIR = 'inbox' + OUTBOX_SUBDIR = 'outbox' + # Hard cap on a single attachment. The HTTP upload endpoints already cap + # uploads at 10MiB; keep parity. + _ATTACHMENT_MAX_BYTES = 10 * _MIB + # Conservative cap for the exec FALLBACK path only (ARG_MAX / stdout + # truncation). The host-filesystem path has no such limit. + _EXEC_FALLBACK_MAX_BYTES = 256 * 1024 + + def _host_query_dir(self, subdir: str, query_id) -> str | None: + """Host path for ``/workspace//`` when LangBot can + access the bind-mounted workspace directly, else ``None``. + + ``default_workspace`` is the host directory bind-mounted to + ``/workspace`` for the local docker/nsjail backends and shared + outright in stdio mode, so a file written there by LangBot is visible + to the sandbox (and vice-versa). It is ``None`` / not a local dir for + E2B and remote runtimes, where we must fall back to the exec channel. + """ + root = self.default_workspace + if not root or not os.path.isdir(root): + return None + return os.path.join(root, subdir, str(query_id)) + + async def _purge_attachment_dirs(self) -> None: + """Remove leftover inbox/outbox directories on startup. + + ``query_id`` is a process-local counter (see pipeline query pool) that + resets to 0 on every restart, so per-query attachment directories from + a previous process would otherwise be silently reused — leaking a prior + run's inbound files and re-sending stale outbound files. + + Outbox files are written by the sandbox **container**, which runs as + root over the bind-mount, so the LangBot host process (a non-root user) + cannot ``rmtree`` them. We therefore try a host-side delete first (fast, + works for host-owned inbox files) and, for anything that survives, + delete from *inside* the sandbox via exec where the container's root can + remove its own files. Best-effort: never block startup. + """ + root = self.default_workspace + if not root or not os.path.isdir(root): + return + + import shutil + + host_survivors: list[str] = [] + + def _host_purge() -> list[str]: + survivors: list[str] = [] + for subdir in (self.INBOX_SUBDIR, self.OUTBOX_SUBDIR): + path = os.path.join(root, subdir) + if not os.path.isdir(path): + continue + shutil.rmtree(path, ignore_errors=True) + if os.path.exists(path): + survivors.append(subdir) + return survivors + + try: + host_survivors = await asyncio.to_thread(_host_purge) + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.warning(f'Host-side purge of sandbox attachment dirs failed: {exc}') + host_survivors = [self.INBOX_SUBDIR, self.OUTBOX_SUBDIR] + + if not host_survivors: + self.ap.logger.info('Purged leftover sandbox attachment dirs from a previous process.') + return + + # Root-owned leftovers (container output): delete from inside the box. + targets = ' '.join(f'/workspace/{sub}' for sub in host_survivors) + try: + spec = self.build_spec({'cmd': f'rm -rf {targets}', 'session_id': '__startup_purge__', 'timeout_sec': 30}) + await self.client.execute(spec) + self.ap.logger.info( + f'Purged root-owned leftover sandbox attachment dirs via sandbox exec: {host_survivors}' + ) + except Exception as exc: + self.ap.logger.warning( + f'Failed to purge root-owned sandbox attachment dirs {host_survivors} via exec: {exc}' + ) + + @staticmethod + def _sanitize_attachment_name(name: str, fallback: str) -> str: + """Reduce an arbitrary attachment name to a safe basename. + + Strips directory separators and parent refs so a crafted file name + can never escape the inbox/outbox directory. + """ + base = os.path.basename(str(name or '').replace('\\', '/').strip()) + base = base.lstrip('.') or '' + # Drop anything that is not a conservative filename charset. + cleaned = ''.join(c for c in base if c.isalnum() or c in ('.', '_', '-', ' ')).strip() + cleaned = cleaned.replace(' ', '_') + return cleaned or fallback + + @staticmethod + async def _component_to_bytes(component) -> tuple[bytes, str] | None: + """Best-effort extraction of (bytes, mime) from a platform component. + + Handles base64, http(s) url and local path sources. Returns None when + no payload can be resolved. + """ + import base64 as _b64 + + b64 = getattr(component, 'base64', None) + if b64: + data = b64 + mime = 'application/octet-stream' + if isinstance(data, str) and data.startswith('data:'): + split_index = data.find(';base64,') + if split_index != -1: + mime = data[5:split_index] + data = data[split_index + 8 :] + try: + return _b64.b64decode(data), mime + except Exception: + return None + + url = getattr(component, 'url', None) + if url: + try: + import httpx + + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url) + resp.raise_for_status() + return resp.content, resp.headers.get('Content-Type', 'application/octet-stream') + except Exception: + return None + + path = getattr(component, 'path', None) + if path: + try: + import aiofiles + + async with aiofiles.open(path, 'rb') as f: + return await f.read(), 'application/octet-stream' + except Exception: + return None + + return None + + async def _write_files_into_sandbox( + self, + query: pipeline_query.Query, + subdir: str, + target_mount_dir: str, + files: list[tuple[str, bytes]], + ) -> list[str]: + """Write *files* (name, bytes) into the per-query directory. + + Prefers a direct host-filesystem write to the bind-mounted workspace + (no size limit). Falls back to a base64-through-exec round-trip only + when the workspace is not visible on the LangBot host (E2B / remote). + Returns the list of in-sandbox paths actually written. + """ + if not files: + return [] + + host_dir = self._host_query_dir(subdir, query.query_id) + if host_dir is not None: + return await asyncio.to_thread(self._write_files_host, host_dir, target_mount_dir, files) + + return await self._write_files_via_exec(query, target_mount_dir, files) + + def _write_files_host( + self, + host_dir: str, + target_mount_dir: str, + files: list[tuple[str, bytes]], + ) -> list[str]: + """Write attachments straight onto the bind-mounted host directory. + + Recreates the per-query directory from scratch so a reused query_id + (the webchat session uses small sequential ids) never inherits stale + files from an earlier turn. + """ + import shutil + + shutil.rmtree(host_dir, ignore_errors=True) + os.makedirs(host_dir, exist_ok=True) + written: list[str] = [] + for name, data in files: + with open(os.path.join(host_dir, name), 'wb') as fh: + fh.write(data) + written.append(f'{target_mount_dir}/{name}') + return written + + async def _write_files_via_exec( + self, + query: pipeline_query.Query, + target_dir: str, + files: list[tuple[str, bytes]], + ) -> list[str]: + """Fallback: ship files into the sandbox over the exec channel. + + Only used for backends without host-filesystem access (E2B / remote). + Each file is base64-decoded inside the sandbox. Files larger than the + conservative exec cap are skipped (ARG_MAX / stdout limits). + """ + import base64 as _b64 + import json as _json + + manifest = [] + for name, data in files: + if len(data) > self._EXEC_FALLBACK_MAX_BYTES: + self.ap.logger.warning( + f'Attachment "{name}" ({len(data)} bytes) exceeds the exec-channel ' + f'fallback limit ({self._EXEC_FALLBACK_MAX_BYTES} bytes); skipping. ' + f'Configure a host-shared workspace to transfer large files.' + ) + continue + manifest.append({'name': name, 'b64': _b64.b64encode(data).decode('ascii')}) + if not manifest: + return [] + + manifest_b64 = _b64.b64encode(_json.dumps(manifest).encode('utf-8')).decode('ascii') + script = ( + 'import base64, json, os, shutil\n' + f'target = {target_dir!r}\n' + 'shutil.rmtree(target, ignore_errors=True)\n' + 'os.makedirs(target, exist_ok=True)\n' + f'manifest = json.loads(base64.b64decode({manifest_b64!r}))\n' + 'written = []\n' + 'for item in manifest:\n' + " p = os.path.join(target, item['name'])\n" + " with open(p, 'wb') as f:\n" + " f.write(base64.b64decode(item['b64']))\n" + ' written.append(p)\n' + 'print(json.dumps(written))\n' + ) + result = await self.execute_tool( + {'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120}, + query, + ) + if not result.get('ok'): + self.ap.logger.warning( + f'Failed to write inbound attachments into sandbox via exec: ' + f'query_id={query.query_id} stderr={result.get("stderr", "")[:200]}' + ) + return [] + try: + return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1]) + except Exception: + return [] + + async def materialize_inbound_attachments(self, query: pipeline_query.Query) -> list[dict]: + """Persist message-chain attachments into the sandbox inbox. + + Returns a list of ``{path, name, type, size}`` describing what was + written, so the runner can tell the LLM the exact in-sandbox paths. + Returns ``[]`` when sandbox is unavailable or there are no attachments. + """ + if not self._available: + return [] + + import langbot_plugin.api.entities.builtin.platform.message as platform_message + + message_chain = getattr(query, 'message_chain', None) + if not message_chain: + return [] + + type_map = [ + (platform_message.Image, 'Image', 'image', 'png'), + (platform_message.Voice, 'Voice', 'voice', 'wav'), + (platform_message.File, 'File', 'file', 'bin'), + ] + + pending: list[tuple[str, bytes]] = [] + descriptors: list[dict] = [] + index = 0 + for component in message_chain: + matched = None + for cls, kind, prefix, default_ext in type_map: + if isinstance(component, cls): + matched = (kind, prefix, default_ext) + break + if matched is None: + continue + kind, prefix, default_ext = matched + + payload = await self._component_to_bytes(component) + if payload is None: + continue + data, _mime = payload + if not data or len(data) > self._ATTACHMENT_MAX_BYTES: + continue + + index += 1 + raw_name = getattr(component, 'name', None) or f'{prefix}_{index}.{default_ext}' + safe_name = self._sanitize_attachment_name(raw_name, f'{prefix}_{index}.{default_ext}') + pending.append((safe_name, data)) + descriptors.append( + { + 'name': safe_name, + 'type': kind, + 'size': len(data), + } + ) + + if not pending: + return [] + + target_dir = f'{self.INBOX_MOUNT_DIR}/{query.query_id}' + written = await self._write_files_into_sandbox(query, self.INBOX_SUBDIR, target_dir, pending) + written_basenames = {os.path.basename(p) for p in written} + + result: list[dict] = [] + for desc in descriptors: + if desc['name'] in written_basenames: + desc['path'] = f'{target_dir}/{desc["name"]}' + result.append(desc) + if result: + self.ap.logger.info( + f'Materialized {len(result)} inbound attachment(s) into sandbox: ' + f'query_id={query.query_id} dir={target_dir}' + ) + return result + + async def collect_outbound_attachments(self, query: pipeline_query.Query) -> list[dict]: + """Collect files the agent produced in the sandbox outbox. + + Reads ``/workspace/outbox//`` (recursively) — directly from + the bind-mounted host directory when available (no size limit), else + via the exec channel — returns a list of ``{type, name, base64}`` + ready to become platform message components, then clears the outbox so + a later turn in the same session does not re-send stale files. Returns + ``[]`` when nothing was produced. + """ + if not self._available: + return [] + + host_dir = self._host_query_dir(self.OUTBOX_SUBDIR, query.query_id) + if host_dir is not None: + entries = await asyncio.to_thread(self._read_outbox_host, host_dir) + else: + entries = await self._read_outbox_via_exec(query) + + attachments = self._classify_outbound_entries(entries) + + # Always clear the per-query outbox after reading — even when nothing + # was collected — so a later turn that reuses the same query_id (the + # counter resets across restarts) never inherits stale files. + await self._clear_outbox(query, host_dir) + if attachments: + self.ap.logger.info( + f'Collected {len(attachments)} outbound attachment(s) from sandbox: query_id={query.query_id}' + ) + return attachments + + def _read_outbox_host(self, host_dir: str) -> list[dict]: + """Read outbox files straight off the bind-mounted host directory.""" + import base64 as _b64 + + entries: list[dict] = [] + if not os.path.isdir(host_dir): + return entries + for root, _dirs, names in os.walk(host_dir): + for name in sorted(names): + path = os.path.join(root, name) + try: + if os.path.getsize(path) > self._ATTACHMENT_MAX_BYTES: + continue + with open(path, 'rb') as fh: + data = fh.read() + except OSError: + continue + rel = os.path.relpath(path, host_dir) + entries.append({'name': rel, 'b64': _b64.b64encode(data).decode('ascii')}) + return entries + + async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]: + """Fallback: read the outbox over the exec channel (E2B / remote). + + Note: exec stdout is truncated by ``output_limit_chars``, so this path + only reliably transfers small files. The host path is preferred. + """ + import json as _json + + target_dir = f'{self.OUTBOX_MOUNT_DIR}/{query.query_id}' + max_bytes = self._EXEC_FALLBACK_MAX_BYTES + script = ( + 'import base64, json, os\n' + f'target = {target_dir!r}\n' + f'max_bytes = {max_bytes}\n' + 'out = []\n' + 'if os.path.isdir(target):\n' + ' for root, _dirs, names in os.walk(target):\n' + ' for n in sorted(names):\n' + ' p = os.path.join(root, n)\n' + ' try:\n' + ' if os.path.getsize(p) > max_bytes:\n' + ' continue\n' + " with open(p, 'rb') as f:\n" + ' data = f.read()\n' + ' except OSError:\n' + ' continue\n' + ' rel = os.path.relpath(p, target)\n' + " out.append({'name': rel, 'b64': base64.b64encode(data).decode('ascii')})\n" + 'print(json.dumps(out))\n' + ) + result = await self.execute_tool( + {'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120}, + query, + ) + if not result.get('ok'): + return [] + try: + return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1]) + except Exception: + return [] + + async def _clear_outbox(self, query: pipeline_query.Query, host_dir: str | None) -> None: + """Empty the per-query outbox after collection. + + Tries a host-side ``rmtree`` first (fast, no container round-trip). + Outbox files are created by the sandbox container as root over the + bind-mount, so when LangBot runs as a non-root user the host delete + fails silently and the files survive — they would then be re-collected + on the next turn that reuses the same query_id. So if anything survives + the host delete, clear it from *inside* the sandbox via exec, where the + container's root can remove its own files. Best-effort: never raise + into the pipeline. + """ + target_dir = f'{self.OUTBOX_MOUNT_DIR}/{query.query_id}' + + if host_dir is not None: + import shutil + + def _clear() -> bool: + shutil.rmtree(host_dir, ignore_errors=True) + survived = os.path.exists(host_dir) and bool(os.listdir(host_dir)) + os.makedirs(host_dir, exist_ok=True) + return survived + + survived = await asyncio.to_thread(_clear) + if not survived: + return + # Root-owned container files survived the host delete — fall through. + + try: + await self.execute_tool( + {'command': f'rm -rf {target_dir} && mkdir -p {target_dir}', 'timeout_sec': 30}, + query, + ) + except Exception as exc: + self.ap.logger.warning(f'Failed to clear sandbox outbox {target_dir}: {exc}') + + @staticmethod + def _classify_outbound_entries(entries: list[dict]) -> list[dict]: + """Classify outbox files into Image/Voice/File component descriptors.""" + image_exts = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'} + voice_exts = {'wav', 'mp3', 'silk', 'amr', 'ogg', 'm4a', 'aac'} + mime_by_ext = { + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'bmp': 'image/bmp', + } + attachments: list[dict] = [] + for entry in entries or []: + name = str(entry.get('name', '') or '') + b64 = entry.get('b64') + if not name or not b64: + continue + ext = name.rsplit('.', 1)[-1].lower() if '.' in name else '' + base_name = os.path.basename(name) + if ext in image_exts: + mime = mime_by_ext.get(ext, 'image/png') + attachments.append({'type': 'Image', 'name': base_name, 'base64': f'data:{mime};base64,{b64}'}) + elif ext in voice_exts: + attachments.append({'type': 'Voice', 'name': base_name, 'base64': f'data:audio/{ext};base64,{b64}'}) + else: + attachments.append({'type': 'File', 'name': base_name, 'base64': b64}) + return attachments + + async def shutdown(self): + await self.client.shutdown() + + def dispose(self): + if self._runtime_connector is not None: + self._runtime_connector.dispose() + loop = getattr(self.ap, 'event_loop', None) + if loop is not None and not loop.is_closed() and (self._shutdown_task is None or self._shutdown_task.done()): + self._shutdown_task = loop.create_task(self.shutdown()) + + async def get_sessions(self) -> list[dict]: + if not self._available: + return [] + try: + return await self.client.get_sessions() + except Exception: + return [] + + def build_spec(self, spec_payload: dict, skip_host_mount_validation: bool = False) -> BoxSpec: + spec_payload = dict(spec_payload) + spec_payload.setdefault('env', {}) + if spec_payload.get('host_path') in (None, '') and self.default_workspace is not None: + spec_payload['host_path'] = self.default_workspace + if spec_payload.get('workspace_quota_mb') in (None, '') and self.workspace_quota_mb is not None: + spec_payload['workspace_quota_mb'] = self.workspace_quota_mb + + # Global custom image overrides profile default (but not caller-specified image) + if self.custom_image and 'image' not in spec_payload: + spec_payload['image'] = self.custom_image + + self._apply_profile(spec_payload) + + try: + spec = BoxSpec.model_validate(spec_payload) + except pydantic.ValidationError as exc: + first_error = exc.errors()[0] + raise BoxValidationError(first_error.get('msg', 'invalid box arguments')) from exc + + if not skip_host_mount_validation: + self._validate_host_mount(spec) + return spec + + async def create_session(self, spec_payload: dict, *, skip_host_mount_validation: bool = False) -> dict: + spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation) + return await self.client.create_session(spec) + + async def start_managed_process(self, session_id: str, process_payload: dict) -> BoxManagedProcessInfo: + process_spec = BoxManagedProcessSpec.model_validate(process_payload) + return await self.client.start_managed_process(session_id, process_spec) + + async def get_managed_process(self, session_id: str, process_id: str = 'default') -> BoxManagedProcessInfo: + return await self.client.get_managed_process(session_id, process_id) + + async def stop_managed_process(self, session_id: str, process_id: str = 'default') -> None: + return await self.client.stop_managed_process(session_id, process_id) + + def get_managed_process_websocket_url(self, session_id: str, process_id: str = 'default') -> str: + getter = getattr(self.client, 'get_managed_process_websocket_url', None) + if getter is None: + raise BoxValidationError('box runtime client does not support managed process websocket attach') + ws_relay_base_url = ( + self._runtime_connector.ws_relay_base_url + if self._runtime_connector is not None + else 'http://127.0.0.1:5410' + ) + return getter(session_id, ws_relay_base_url, process_id) + + async def list_skills(self) -> list[dict]: + return await self.client.list_skills() + + async def get_skill(self, name: str) -> dict | None: + return await self.client.get_skill(name) + + async def create_skill(self, skill: dict) -> dict: + return await self.client.create_skill(skill) + + async def update_skill(self, name: str, skill: dict) -> dict: + return await self.client.update_skill(name, skill) + + async def delete_skill(self, name: str) -> None: + await self.client.delete_skill(name) + + async def scan_skill_directory(self, path: str) -> dict: + return await self.client.scan_skill_directory(path) + + async def list_skill_files( + self, + name: str, + path: str = '.', + include_hidden: bool = False, + max_entries: int = 200, + ) -> dict: + return await self.client.list_skill_files(name, path, include_hidden, max_entries) + + async def read_skill_file(self, name: str, path: str) -> dict: + return await self.client.read_skill_file(name, path) + + async def write_skill_file(self, name: str, path: str, content: str) -> dict: + return await self.client.write_skill_file(name, path, content) + + async def preview_skill_zip( + self, + file_bytes: bytes, + filename: str, + source_subdir: str = '', + target_suffix: str = 'upload', + ) -> list[dict]: + return await self.client.preview_skill_zip(file_bytes, filename, source_subdir, target_suffix) + + async def install_skill_zip( + self, + file_bytes: bytes, + filename: str, + source_paths: list[str] | None = None, + source_path: str = '', + source_subdir: str = '', + target_suffix: str = 'upload', + ) -> list[dict]: + return await self.client.install_skill_zip( + file_bytes, + filename, + source_paths, + source_path, + source_subdir, + target_suffix, + ) + + def _serialize_result(self, result: BoxExecutionResult) -> dict: + stdout, stdout_truncated = self._truncate(result.stdout) + stderr, stderr_truncated = self._truncate(result.stderr) + + return { + 'session_id': result.session_id, + 'backend': result.backend_name, + 'status': result.status.value, + 'ok': result.ok, + 'exit_code': result.exit_code, + 'stdout': stdout, + 'stderr': stderr, + 'stdout_truncated': stdout_truncated, + 'stderr_truncated': stderr_truncated, + 'duration_ms': result.duration_ms, + } + + def _truncate(self, text: str) -> tuple[str, bool]: + if len(text) <= self.output_limit_chars: + return text, False + if self.output_limit_chars <= 0: + return '', True + + head_size = 0 + tail_size = 0 + notice = '' + # Recompute once the omitted count is known so the final payload + # stays within output_limit_chars even after adding the notice. + for _ in range(4): + omitted = max(len(text) - head_size - tail_size, 0) + notice = f'\n\n... [{omitted} characters truncated] ...\n\n' + available = self.output_limit_chars - len(notice) + if available <= 0: + return notice[: self.output_limit_chars], True + + new_head_size = int(available * 0.6) + new_tail_size = available - new_head_size + if new_head_size == head_size and new_tail_size == tail_size: + break + head_size = new_head_size + tail_size = new_tail_size + + head = text[:head_size] + tail = text[-tail_size:] if tail_size else '' + truncated = f'{head}{notice}{tail}' + return truncated[: self.output_limit_chars], True + + def _summarize_spec(self, spec: BoxSpec) -> dict: + cmd = spec.cmd.strip() + if len(cmd) > 400: + cmd = f'{cmd[:397]}...' + + return { + 'session_id': spec.session_id, + 'workdir': spec.workdir, + 'mount_path': spec.mount_path, + 'timeout_sec': spec.timeout_sec, + 'network': spec.network.value, + 'image': spec.image, + 'host_path': spec.host_path, + 'host_path_mode': spec.host_path_mode.value, + 'cpus': spec.cpus, + 'memory_mb': spec.memory_mb, + 'pids_limit': spec.pids_limit, + 'read_only_rootfs': spec.read_only_rootfs, + 'workspace_quota_mb': spec.workspace_quota_mb, + 'env_keys': sorted(spec.env.keys()), + 'cmd': cmd, + } + + def _summarize_result(self, result: BoxExecutionResult) -> dict: + stdout_preview = result.stdout[:200] + stderr_preview = result.stderr[:200] + if len(result.stdout) > 200: + stdout_preview = f'{stdout_preview}...' + if len(result.stderr) > 200: + stderr_preview = f'{stderr_preview}...' + + return { + 'session_id': result.session_id, + 'backend': result.backend_name, + 'status': result.status.value, + 'exit_code': result.exit_code, + 'duration_ms': result.duration_ms, + 'stdout_preview': stdout_preview, + 'stderr_preview': stderr_preview, + } + + def _local_config(self) -> dict: + """Return ``box.local`` from instance config. + + Environment overrides are applied uniformly by + ``LoadConfigStage._apply_env_overrides_to_config`` (e.g. + ``BOX__LOCAL__HOST_ROOT``) before this is read, so no box-specific + env parsing happens here. + """ + return dict(_get_box_config(self.ap).get('local') or {}) + + def _load_allowed_mount_roots(self) -> list[str]: + configured_roots = self._local_config().get('allowed_mount_roots', []) + # The unified env-override mechanism stores a brand-new key as a raw + # string when the key is absent from config.yaml. Accept a + # comma-separated string as well as a list so that + # ``BOX__LOCAL__ALLOWED_MOUNT_ROOTS="/a,/b"`` keeps working even when + # the config file has no ``box.local.allowed_mount_roots`` entry. + if isinstance(configured_roots, str): + configured_roots = [item.strip() for item in configured_roots.split(',') if item.strip()] + + normalized_roots: list[str] = [] + for root in configured_roots: + root_value = str(root).strip() + if not root_value: + continue + normalized_roots.append(os.path.realpath(os.path.abspath(root_value))) + + if not normalized_roots and self.host_root is not None: + normalized_roots.append(self.host_root) + + return normalized_roots + + def _load_host_root(self) -> str | None: + host_root = str(self._local_config().get('host_root', '')).strip() + if not host_root: + return None + return os.path.realpath(os.path.abspath(host_root)) + + def _load_default_workspace(self) -> str | None: + default_workspace = str(self._local_config().get('default_workspace', '')).strip() + if not default_workspace: + if self.host_root is None: + return None + default_workspace = os.path.join(self.host_root, 'default') + elif not os.path.isabs(default_workspace) and self.host_root is not None: + default_workspace = os.path.join(self.host_root, default_workspace) + return os.path.realpath(os.path.abspath(default_workspace)) + + def get_skills_root(self) -> str | None: + skills_root = str(self._local_config().get('skills_root', '') or 'skills').strip() + if not skills_root: + skills_root = 'skills' + if not os.path.isabs(skills_root) and self.host_root is not None: + skills_root = os.path.join(self.host_root, skills_root) + return os.path.realpath(os.path.abspath(skills_root)) + + def _load_enabled(self) -> bool: + """Read ``box.enabled`` (top-level, not ``box.local.*``). Default True + — disabling is opt-in. Accepts bool, ``'true'``/``'false'`` strings, + and the standard env-overridden truthy values that + ``LoadConfigStage._apply_env_overrides_to_config`` produces.""" + raw = _get_box_config(self.ap).get('enabled', True) + if isinstance(raw, bool): + return raw + return str(raw).strip().lower() not in ('false', '0', 'no', 'off', '') + + def _load_custom_image(self) -> str | None: + raw = str(self._local_config().get('image', '') or '').strip() + return raw or None + + def _forced_box_session_id_template(self) -> str: + """Return the SaaS-forced sandbox-scope template, or '' when unset. + + Read from ``system.limitation.force_box_session_id_template``. A + non-empty value pins every pipeline to a single sandbox scope + (e.g. ``'{global}'``) and cannot be overridden per-pipeline. + """ + limitation = ( + (self.ap.instance_config.data or {}).get('system', {}).get('limitation', {}) + if getattr(self.ap, 'instance_config', None) is not None + else {} + ) + return str(limitation.get('force_box_session_id_template', '') or '').strip() + + def _load_workspace_quota_mb(self) -> int | None: + raw_value = self._local_config().get('workspace_quota_mb') + if raw_value in (None, ''): + return None + try: + value = _INT_ADAPTER.validate_python(raw_value) + except pydantic.ValidationError as exc: + raise BoxValidationError('workspace_quota_mb must be an integer greater than or equal to 0') from exc + if value < 0: + raise BoxValidationError('workspace_quota_mb must be greater than or equal to 0') + return value + + def _ensure_default_workspace(self): + if self.default_workspace is None: + return + + if not self.shares_filesystem_with_box: + return + + if os.path.isdir(self.default_workspace): + return + + if os.path.exists(self.default_workspace): + raise BoxValidationError('box.local.default_workspace must point to a directory on the host') + + if not self.allowed_mount_roots: + raise BoxValidationError( + 'box.local.default_workspace cannot be created because no allowed_mount_roots are configured' + ) + + for allowed_root in self.allowed_mount_roots: + if _is_path_under(self.default_workspace, allowed_root): + os.makedirs(self.default_workspace, exist_ok=True) + return + + allowed_roots = ', '.join(self.allowed_mount_roots) + raise BoxValidationError(f'box.local.default_workspace is outside allowed_mount_roots: {allowed_roots}') + + def _validate_host_mount(self, spec: BoxSpec): + if spec.host_path is None: + return + + host_path = os.path.realpath(spec.host_path) + if self.shares_filesystem_with_box and not os.path.isdir(host_path): + raise BoxValidationError('host_path must point to an existing directory on the host') + + if not self.allowed_mount_roots: + raise BoxValidationError('host_path mounting is disabled because no allowed_mount_roots are configured') + + for allowed_root in self.allowed_mount_roots: + if _is_path_under(host_path, allowed_root): + return + + allowed_roots = ', '.join(self.allowed_mount_roots) + raise BoxValidationError(f'host_path is outside allowed_mount_roots: {allowed_roots}') + + def _load_profile(self) -> BoxProfile: + profile_name = str(self._local_config().get('profile', 'default')).strip() or 'default' + + profile = BUILTIN_PROFILES.get(profile_name) + if profile is None: + available = ', '.join(sorted(BUILTIN_PROFILES)) + raise BoxValidationError(f"unknown box profile '{profile_name}', available profiles: {available}") + return profile + + def _apply_profile(self, params: dict): + """Merge profile defaults into *params* in-place, enforce locked fields and clamp timeout.""" + profile = self.profile + _PROFILE_FIELDS = ( + 'image', + 'network', + 'timeout_sec', + 'host_path_mode', + 'cpus', + 'memory_mb', + 'pids_limit', + 'read_only_rootfs', + 'workspace_quota_mb', + ) + + for field in _PROFILE_FIELDS: + profile_value = getattr(profile, field) + raw_value = profile_value.value if isinstance(profile_value, enum.Enum) else profile_value + + if field in profile.locked: + params[field] = raw_value + elif field not in params: + params[field] = raw_value + + timeout = params.get('timeout_sec') + try: + normalized_timeout = _INT_ADAPTER.validate_python(timeout) + except pydantic.ValidationError: + return + + if normalized_timeout > profile.max_timeout_sec: + params['timeout_sec'] = profile.max_timeout_sec + + def _get_workspace_size_bytes(self, root: str) -> int: + total = 0 + + def _walk(path: str): + nonlocal total + try: + with os.scandir(path) as entries: + for entry in entries: + try: + if entry.is_symlink(): + total += entry.stat(follow_symlinks=False).st_size + continue + if entry.is_dir(follow_symlinks=False): + _walk(entry.path) + continue + total += entry.stat(follow_symlinks=False).st_size + except FileNotFoundError: + continue + except FileNotFoundError: + return + + _walk(root) + return total + + async def _enforce_workspace_quota(self, spec: BoxSpec, *, phase: str) -> None: + if spec.host_path is None or spec.workspace_quota_mb <= 0: + return + + host_path = os.path.realpath(spec.host_path) + if not os.path.isdir(host_path): + return + + # Walk the workspace off the event loop — this runs on every + # quota-enforced exec, and a large tree would otherwise block the whole + # asyncio runtime (all bots/pipelines) for the duration of the scan. + used_bytes = await asyncio.to_thread(self._get_workspace_size_bytes, host_path) + limit_bytes = spec.workspace_quota_mb * _MIB + if used_bytes <= limit_bytes: + return + + raise BoxValidationError( + f'workspace quota exceeded {phase}: ' + f'used={used_bytes} bytes limit={limit_bytes} bytes ' + f'host_path={host_path} session_id={spec.session_id}' + ) + + async def _cleanup_exceeded_session(self, spec: BoxSpec) -> None: + try: + await self.client.delete_session(spec.session_id) + except Exception as exc: + self.ap.logger.warning( + 'Failed to clean up Box session after workspace quota was exceeded: ' + f'session_id={spec.session_id} error={exc}' + ) + + # ── Observability ───────────────────────────────────────────────── + + def _record_error(self, exc: Exception, query: pipeline_query.Query): + telemetry_features.increment(query, 'sandbox', 'errors') + self._recent_errors.append( + { + 'timestamp': _dt.datetime.now(_UTC).isoformat(), + 'type': type(exc).__name__, + 'message': str(exc), + 'query_id': str(query.query_id), + } + ) + + def get_recent_errors(self) -> list[dict]: + return list(self._recent_errors) + + def get_system_guidance(self, query_id=None) -> str: + """Return LLM system-prompt guidance for the exec tool. + + All execution-specific prompt text is kept here so that callers + (e.g. LocalAgentRunner) stay free of box domain knowledge. + + ``query_id`` is the current turn's pipeline query id. When provided, + the guidance ALWAYS advertises the per-query outbox path so the agent + knows how to deliver generated files back to the user — even on turns + where the user sent no inbound attachment (e.g. "generate a QR code"), + which is exactly when the inbound-attachment note never fires. Outbound + collection in the wrapper runs on every turn regardless of inbound + files, so without this the file would be produced and silently dropped. + """ + guidance = ( + 'When the exec tool is available, use it for exact calculations, statistics, structured data parsing, ' + 'and code execution instead of estimating mentally. If the user provides numbers, tables, CSV-like text, ' + 'JSON, or other data and asks for a computed answer, prefer running a short Python script via exec ' + 'and then answer from the tool result. Unless the user explicitly asks for the script, code, or implementation ' + 'details, do not include the generated script in the final answer; return the result and a brief explanation only.' + ) + if self.default_workspace: + guidance += ( + ' A default workspace is mounted at /workspace for file tasks. When the user asks to read, create, or ' + 'modify local files in the working directory, use exec with /workspace paths directly; do not ask the ' + 'user for directory parameters unless they explicitly need a different directory.' + ) + if query_id is not None: + outbox_dir = f'{self.OUTBOX_MOUNT_DIR}/{query_id}' + guidance += ( + f' If you produce any file (image, audio, document, etc.) that should be sent back to the user, ' + f'write it into {outbox_dir}/ (create the directory if needed). Every file placed there will be ' + 'delivered to the user automatically; do not paste file contents or base64 into your reply.' + ) + return guidance + + async def get_status(self) -> dict: + if not self._available: + return { + 'available': False, + 'enabled': self._enabled, + 'profile': self.profile.name, + 'recent_error_count': len(self._recent_errors), + 'connector_error': self._connector_error, + } + try: + runtime_status = await self.client.get_status() + except Exception as exc: + # RPC failed — the runtime likely just disconnected and the + # heartbeat hasn't flipped _available yet. + return { + 'available': False, + 'enabled': self._enabled, + 'profile': self.profile.name, + 'recent_error_count': len(self._recent_errors), + 'connector_error': str(exc), + } + # Backend state can be unavailable even when the connector is healthy + # (operator selected nsjail but the binary is missing, Docker daemon + # went down after the runtime started, E2B credentials wrong, ...). + # Report the combined state in the top-level ``available`` so the + # frontend banner / ``useBoxStatus`` hook / native-tool gate all + # agree on "actually usable" rather than "connector alive". The + # detailed ``backend`` object stays in the payload so the dialog + # can still show which backend was tried. + backend_info = runtime_status.get('backend') if isinstance(runtime_status, dict) else None + backend_ok = bool(backend_info and backend_info.get('available', False)) + payload = { + **runtime_status, + 'available': backend_ok, + 'enabled': self._enabled, + 'profile': self.profile.name, + 'recent_error_count': len(self._recent_errors), + } + if not backend_ok and 'connector_error' not in payload: + backend_name = backend_info.get('name') if backend_info else None + if backend_name: + payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable' + else: + payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available' + return payload diff --git a/src/langbot/pkg/box/workspace.py b/src/langbot/pkg/box/workspace.py new file mode 100644 index 000000000..26d1a41e9 --- /dev/null +++ b/src/langbot/pkg/box/workspace.py @@ -0,0 +1,430 @@ +"""Reusable workspace/session helpers built on top of Box. + +This module is the middle layer between the raw Box runtime primitives and +application-specific flows such as skills or MCP stdio. + +It intentionally stays generic: +- path and virtualenv rewriting are workspace concerns +- Python project detection/bootstrap are workspace concerns +- session exec / managed-process helpers are workspace concerns + +Higher layers add their own semantics on top, for example: +- skills choose a stable per-skill session id and use repeated exec +- MCP stdio chooses how to prepare dependencies and attaches to a managed process +""" + +from __future__ import annotations + +import os +import textwrap +from typing import Any + +PYTHON_MANIFEST_FILES = ( + 'requirements.txt', + 'pyproject.toml', + 'setup.py', + 'setup.cfg', +) +_VENV_DIRS = frozenset({'.venv', 'venv', 'env', '.env'}) +_VENV_BIN_DIRS = frozenset({'bin', 'Scripts'}) + + +def normalize_host_path(path: str | None) -> str: + if path is None: + return '' + stripped = str(path).strip() + if not stripped: + return '' + return os.path.realpath(os.path.abspath(stripped)) + + +def rewrite_mounted_path(path: str, host_path: str | None, *, mount_path: str = '/workspace') -> str: + """Translate a host path into the path visible inside the sandbox mount.""" + if not host_path or not path: + return path + normalized_host = os.path.realpath(host_path) + normalized_path = os.path.realpath(path) + if normalized_path.startswith(normalized_host + '/'): + return mount_path + normalized_path[len(normalized_host) :] + if normalized_path == normalized_host: + return mount_path + return path + + +def unwrap_venv_path(directory: str) -> str: + """Collapse ``.../.venv/bin`` style paths back to the project root.""" + parts = directory.replace('\\', '/').split('/') + for i in range(len(parts) - 1, 0, -1): + if parts[i] in _VENV_BIN_DIRS and i >= 1: + venv_dir = parts[i - 1] + if venv_dir in _VENV_DIRS: + project_root = '/'.join(parts[: i - 1]) + return project_root if project_root else '/' + return directory + + +def infer_workspace_host_path(command: str, args: list[str] | None = None) -> str | None: + """Infer the project/workspace root from absolute command/arg paths.""" + candidates: list[str] = [] + for part in [command, *(args or [])]: + if not os.path.isabs(part): + continue + if os.path.exists(part): + directory = os.path.dirname(part) + candidates.append(os.path.realpath(unwrap_venv_path(directory))) + if not candidates: + return None + common = os.path.commonpath(candidates) + return common if common != '/' else None + + +def rewrite_venv_command(command: str, host_path: str | None, *, mount_path: str = '/workspace') -> str: + """Rewrite host venv interpreters to plain ``python`` inside the sandbox. + + Once a project is mounted into the sandbox, host virtualenv paths are no + longer valid. For those paths we intentionally drop down to ``python`` and + let the sandbox-side environment/bootstrap decide what interpreter to use. + """ + if not host_path or not command: + return command + normalized_host = os.path.realpath(host_path) + normalized_command = os.path.realpath(command) + if not normalized_command.startswith(normalized_host + '/'): + return command + rel = normalized_command[len(normalized_host) + 1 :] + parts = rel.replace('\\', '/').split('/') + if len(parts) >= 3 and parts[0] in _VENV_DIRS and parts[1] in _VENV_BIN_DIRS and parts[2].startswith('python'): + return 'python' + return rewrite_mounted_path(normalized_command, host_path, mount_path=mount_path) + + +def list_python_manifest_files(host_path: str | None) -> list[str]: + normalized_root = normalize_host_path(host_path) + if not normalized_root: + return [] + return [filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))] + + +def classify_python_workspace(host_path: str | None) -> str | None: + """Return the generic Python workspace shape, without app-specific policy.""" + manifest_files = set(list_python_manifest_files(host_path)) + if not manifest_files: + return None + if {'pyproject.toml', 'setup.py', 'setup.cfg'} & manifest_files: + return 'package' + if 'requirements.txt' in manifest_files: + return 'requirements' + return None + + +def should_prepare_python_env(host_path: str | None) -> bool: + normalized_root = normalize_host_path(host_path) + if not normalized_root: + return False + if os.path.isdir(os.path.join(normalized_root, '.venv')): + return True + return bool(list_python_manifest_files(normalized_root)) + + +def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace') -> str: + """Wrap a command with a reusable sandbox-local Python env bootstrap. + + This is the generic "workspace is a Python project" path used by mutable + workspaces such as skills. Read-only installation strategies stay in the + higher-level caller because they are application policy, not workspace + semantics. + """ + bootstrap = textwrap.dedent( + f""" + set -e + + _LB_VENV_DIR="{mount_path}/.venv" + _LB_META_DIR="{mount_path}/.langbot" + _LB_META_FILE="$_LB_META_DIR/python-env.json" + _LB_LOCK_DIR="$_LB_META_DIR/python-env.lock" + _LB_TMP_DIR="{mount_path}/.tmp" + _LB_PIP_CACHE_DIR="{mount_path}/.cache/pip" + + mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR" + _LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)" + if [ -z "$_LB_SYSTEM_PYTHON" ]; then + echo "python3 or python is required to prepare the workspace Python environment" >&2 + exit 127 + fi + + export TMPDIR="$_LB_TMP_DIR" + export TEMP="$_LB_TMP_DIR" + export TMP="$_LB_TMP_DIR" + export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR" + + _lb_python_meta() {{ + "$_LB_SYSTEM_PYTHON" - <<'PY' + import hashlib + import json + import os + import sys + + root = "{mount_path}" + digest = hashlib.sha256() + manifest_files = [] + for rel in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"): + path = os.path.join(root, rel) + if not os.path.isfile(path): + continue + manifest_files.append(rel) + with open(path, "rb") as handle: + digest.update(rel.encode("utf-8")) + digest.update(b"\\0") + digest.update(handle.read()) + digest.update(b"\\0") + + print( + json.dumps( + {{ + "python_executable": sys.executable, + "python_version": list(sys.version_info[:3]), + "manifest_files": manifest_files, + "manifest_sha256": digest.hexdigest(), + }}, + sort_keys=True, + ) + ) + PY + }} + + _LB_CURRENT_META="$(_lb_python_meta)" + _LB_NEEDS_BOOTSTRAP=0 + + if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then + _LB_NEEDS_BOOTSTRAP=1 + elif [ ! -f "$_LB_META_FILE" ]; then + _LB_NEEDS_BOOTSTRAP=1 + elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then + _LB_NEEDS_BOOTSTRAP=1 + fi + + if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then + _LB_LOCK_WAIT=0 + while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do + if [ "$_LB_LOCK_WAIT" -ge 120 ]; then + _LB_LOCK_OWNER="$(cat "$_LB_LOCK_DIR/pid" 2>/dev/null || true)" + if [ -n "$_LB_LOCK_OWNER" ] && kill -0 "$_LB_LOCK_OWNER" 2>/dev/null; then + echo "Timed out waiting for active Python environment lock: $_LB_LOCK_DIR" >&2 + exit 1 + fi + echo "Timed out waiting for Python environment lock, clearing stale lock: $_LB_LOCK_DIR" >&2 + rm -rf "$_LB_LOCK_DIR" 2>/dev/null || true + if mkdir "$_LB_LOCK_DIR" 2>/dev/null; then + break + fi + echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2 + exit 1 + fi + sleep 1 + _LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1)) + done + printf '%s\\n' "$$" > "$_LB_LOCK_DIR/pid" 2>/dev/null || true + + _lb_cleanup_lock() {{ + rm -rf "$_LB_LOCK_DIR" >/dev/null 2>&1 || true + }} + trap _lb_cleanup_lock EXIT INT TERM + + _LB_CURRENT_META="$(_lb_python_meta)" + _LB_NEEDS_BOOTSTRAP=0 + if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then + _LB_NEEDS_BOOTSTRAP=1 + elif [ ! -f "$_LB_META_FILE" ]; then + _LB_NEEDS_BOOTSTRAP=1 + elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then + _LB_NEEDS_BOOTSTRAP=1 + fi + + if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then + rm -rf "$_LB_VENV_DIR" + "$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR" + . "$_LB_VENV_DIR/bin/activate" + python -m pip install --upgrade pip setuptools wheel + if [ -f "{mount_path}/requirements.txt" ]; then + python -m pip install -r "{mount_path}/requirements.txt" + elif [ -f "{mount_path}/pyproject.toml" ] || [ -f "{mount_path}/setup.py" ] || [ -f "{mount_path}/setup.cfg" ]; then + python -m pip install "{mount_path}" + fi + printf '%s' "$_LB_CURRENT_META" > "$_LB_META_FILE" + fi + fi + + export VIRTUAL_ENV="$_LB_VENV_DIR" + export PATH="$_LB_VENV_DIR/bin:$PATH" + {command} + """ + ).strip() + return bootstrap + '\n' + + +class BoxWorkspaceSession: + """High-level handle for one reusable workspace-backed Box session. + + The Box runtime already understands sessions and managed processes. This + wrapper adds LangBot's workspace-centric view on top: a mounted host path, + a stable ``session_id``, optional environment defaults, and convenience + helpers for exec or long-running processes inside that workspace. + """ + + def __init__( + self, + box_service, + session_id: str, + *, + host_path: str | None = None, + host_path_mode: str = 'rw', + workdir: str = '/workspace', + env: dict[str, str] | None = None, + mount_path: str = '/workspace', + network: str | None = None, + read_only_rootfs: bool | None = None, + image: str | None = None, + cpus: float | None = None, + memory_mb: int | None = None, + pids_limit: int | None = None, + persistent: bool = False, + ): + self.box_service = box_service + self.session_id = session_id + self.host_path = host_path + self.host_path_mode = host_path_mode + self.workdir = workdir + self.env = dict(env or {}) + self.mount_path = mount_path + self.network = network + self.read_only_rootfs = read_only_rootfs + self.image = image + self.cpus = cpus + self.memory_mb = memory_mb + self.pids_limit = pids_limit + self.persistent = persistent + + def rewrite_path(self, path: str) -> str: + return rewrite_mounted_path(path, self.host_path, mount_path=self.mount_path) + + def rewrite_venv_command(self, command: str) -> str: + return rewrite_venv_command(command, self.host_path, mount_path=self.mount_path) + + def build_session_payload(self) -> dict[str, Any]: + # Keep this payload generic so callers can reuse the same workspace + # handle for plain exec, file-producing tasks, or managed processes. + payload: dict[str, Any] = { + 'session_id': self.session_id, + 'workdir': self.workdir, + 'env': self.env, + 'persistent': self.persistent, + } + if self.network is not None: + payload['network'] = self.network + if self.read_only_rootfs is not None: + payload['read_only_rootfs'] = self.read_only_rootfs + if self.host_path: + payload['host_path'] = self.host_path + payload['host_path_mode'] = self.host_path_mode + for key in ('image', 'cpus', 'memory_mb', 'pids_limit'): + value = getattr(self, key) + if value is not None: + payload[key] = value + return payload + + def build_exec_payload( + self, + cmd: str, + *, + workdir: str | None = None, + env: dict[str, str] | None = None, + timeout_sec: int | None = None, + ) -> dict[str, Any]: + # Exec payloads inherit the session-level workspace config, then layer + # per-call command/workdir/env overrides on top. + payload = self.build_session_payload() + payload['cmd'] = cmd + payload['workdir'] = workdir or self.workdir + if timeout_sec is not None: + payload['timeout_sec'] = timeout_sec + resolved_env = self.env if env is None else env + if resolved_env: + payload['env'] = resolved_env + elif 'env' in payload and not payload['env']: + payload.pop('env') + return payload + + async def execute_raw( + self, + cmd: str, + *, + workdir: str | None = None, + env: dict[str, str] | None = None, + timeout_sec: int | None = None, + ): + payload = self.build_exec_payload(cmd, workdir=workdir, env=env, timeout_sec=timeout_sec) + return await self.box_service.client.execute(self.box_service.build_spec(payload)) + + async def execute_for_query( + self, + query, + cmd: str, + *, + workdir: str | None = None, + env: dict[str, str] | None = None, + timeout_sec: int | None = None, + ) -> dict: + payload = self.build_exec_payload(cmd, workdir=workdir, env=env, timeout_sec=timeout_sec) + return await self.box_service.execute_spec_payload(payload, query) + + async def create_session(self): + return await self.box_service.create_session(self.build_session_payload()) + + def build_process_payload( + self, + command: str, + args: list[str] | None = None, + *, + env: dict[str, str] | None = None, + cwd: str = '/workspace', + ) -> dict[str, Any]: + # Managed processes run inside the same workspace model as one-shot + # execs, so path/venv rewriting is shared here. + normalized_command = command + normalized_args = list(args or []) + normalized_cwd = cwd + if self.host_path: + normalized_command = self.rewrite_venv_command(command) + normalized_args = [self.rewrite_path(arg) for arg in normalized_args] + normalized_cwd = self.rewrite_path(cwd) + return { + 'command': normalized_command, + 'args': normalized_args, + 'env': dict(env or {}), + 'cwd': normalized_cwd, + } + + async def start_managed_process( + self, + command: str, + args: list[str] | None = None, + *, + process_id: str = 'default', + env: dict[str, str] | None = None, + cwd: str = '/workspace', + ): + payload = self.build_process_payload(command, args, env=env, cwd=cwd) + payload['process_id'] = process_id + return await self.box_service.start_managed_process(self.session_id, payload) + + async def get_managed_process(self, process_id: str = 'default'): + return await self.box_service.get_managed_process(self.session_id, process_id) + + async def stop_managed_process(self, process_id: str = 'default') -> None: + await self.box_service.stop_managed_process(self.session_id, process_id) + + def get_managed_process_websocket_url(self, process_id: str = 'default') -> str: + return self.box_service.get_managed_process_websocket_url(self.session_id, process_id) + + async def cleanup(self) -> None: + await self.box_service.client.delete_session(self.session_id) diff --git a/src/langbot/pkg/command/cmdmgr.py b/src/langbot/pkg/command/cmdmgr.py index a1d7e009c..ee064c219 100644 --- a/src/langbot/pkg/command/cmdmgr.py +++ b/src/langbot/pkg/command/cmdmgr.py @@ -84,7 +84,17 @@ class CommandManager: privilege = 1 - if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']: + import sqlalchemy as _sa + from ..entity.persistence.bot import BotAdmin as _BotAdmin + + _admins = await self.ap.persistence_mgr.execute_async( + _sa.select(_BotAdmin).where( + _BotAdmin.bot_uuid == (query.bot_uuid or ''), + _BotAdmin.launcher_type == query.launcher_type.value, + _BotAdmin.launcher_id == str(query.launcher_id), + ) + ) + if _admins.first() is not None: privilege = 2 ctx = command_context.ExecuteContext( diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 7e5386cf5..b0adb5594 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -9,6 +9,7 @@ from ..platform import botmgr as im_mgr from ..platform.webhook_pusher import WebhookPusher from ..provider.session import sessionmgr as llm_session_mgr from ..provider.modelmgr import modelmgr as llm_model_mgr +from ..box import service as box_service_module from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr from ..config import manager as config_mgr @@ -31,8 +32,8 @@ from ..api.http.service import mcp as mcp_service from ..api.http.service import apikey as apikey_service from ..api.http.service import webhook as webhook_service from ..api.http.service import monitoring as monitoring_service +from ..api.http.service import skill as skill_service from ..api.http.service import maintenance as maintenance_service - from ..discover import engine as discover_engine from ..storage import mgr as storagemgr from ..utils import logcache @@ -43,6 +44,7 @@ from ..rag.service import RAGRuntimeService from ..vector import mgr as vectordb_mgr from ..telemetry import telemetry as telemetry_module from ..survey import manager as survey_module +from ..skill import manager as skill_mgr class Application: @@ -70,6 +72,7 @@ class Application: # TODO move to pipeline tool_mgr: llm_tool_mgr.ToolManager = None + box_service: box_service_module.BoxService = None # ======= Config manager ======= @@ -156,6 +159,10 @@ class Application: monitoring_service: monitoring_service.MonitoringService = None + skill_service: skill_service.SkillService = None + + skill_mgr: skill_mgr.SkillManager = None + maintenance_service: maintenance_service.MaintenanceService = None def __init__(self): @@ -193,6 +200,17 @@ class Application: scopes=[core_entities.LifecycleControlScope.APPLICATION], ) + # Telemetry instance heartbeat (startup + daily); respects + # space.disable_telemetry via TelemetryManager.send(). + if self.telemetry is not None: + from ..telemetry import heartbeat as telemetry_heartbeat + + self.task_mgr.create_task( + telemetry_heartbeat.heartbeat_loop(self), + name='telemetry-heartbeat', + scopes=[core_entities.LifecycleControlScope.APPLICATION], + ) + # Start monitoring data cleanup task if enabled monitoring_cfg = self.instance_config.data.get('monitoring', {}) auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {}) @@ -301,7 +319,10 @@ class Application: return parsed def dispose(self): - self.plugin_connector.dispose() + if self.plugin_connector is not None: + self.plugin_connector.dispose() + if self.box_service is not None: + self.box_service.dispose() async def print_web_access_info(self): """Print access webui tips""" diff --git a/src/langbot/pkg/core/boot.py b/src/langbot/pkg/core/boot.py index f866376bf..f2b335ac8 100644 --- a/src/langbot/pkg/core/boot.py +++ b/src/langbot/pkg/core/boot.py @@ -16,7 +16,6 @@ importutil.import_modules_in_pkg(stages) stage_order = [ 'LoadConfigStage', - 'MigrationStage', 'GenKeysStage', 'SetupLoggerStage', 'BuildAppStage', @@ -62,4 +61,6 @@ async def main(loop: asyncio.AbstractEventLoop): app_inst = await make_app(loop) await app_inst.run() except Exception: + if app_inst is not None: + app_inst.dispose() traceback.print_exc() diff --git a/src/langbot/pkg/core/bootutils/deps.py b/src/langbot/pkg/core/bootutils/deps.py index 1f6530379..2cfd57e0c 100644 --- a/src/langbot/pkg/core/bootutils/deps.py +++ b/src/langbot/pkg/core/bootutils/deps.py @@ -42,6 +42,7 @@ required_deps = { 'telegramify_markdown': 'telegramify-markdown', 'slack_sdk': 'slack_sdk', 'asyncpg': 'asyncpg', + 'litellm': 'litellm', } diff --git a/src/langbot/pkg/core/bootutils/log.py b/src/langbot/pkg/core/bootutils/log.py index 75ed5a924..7b78c93a2 100644 --- a/src/langbot/pkg/core/bootutils/log.py +++ b/src/langbot/pkg/core/bootutils/log.py @@ -1,5 +1,6 @@ import logging import logging.handlers +import os import sys import time @@ -20,6 +21,66 @@ log_colors_config = { LOG_FILE_MAX_BYTES = 10 * 1024 * 1024 # 10MB per file LOG_FILE_BACKUP_COUNT = 5 # Keep 5 backup files (total ~50MB max) +LOG_DIR = 'data/logs' + + +class DailyGroupedRotatingFileHandler(logging.handlers.RotatingFileHandler): + """File handler that writes to ``data/logs/langbot-YYYY-MM-DD.log``. + + It combines two rotation triggers: + + * **Size** — within a single day the file is rotated once it exceeds + ``maxBytes``, producing numbered backups (``langbot-DATE.log.1`` etc.), + exactly like :class:`~logging.handlers.RotatingFileHandler`. + * **Date** — when the local date changes, logging switches to a fresh + ``langbot-.log`` file. This happens even within a single + long-running process, so a bot started on day N keeps writing to that + day's file and rolls over to day N+1's file at midnight, instead of + appending every subsequent day's logs to the start-day file. + + The on-disk naming stays compatible with the log-retention cleanup in + ``api/http/service/maintenance.py`` (``LOG_FILE_PATTERN``). + """ + + def __init__(self, log_dir: str, max_bytes: int, backup_count: int, encoding: str = 'utf-8'): + self.log_dir = log_dir + self._current_date = self._today() + super().__init__( + self._build_path(self._current_date), + maxBytes=max_bytes, + backupCount=backup_count, + encoding=encoding, + ) + + @staticmethod + def _today() -> str: + return time.strftime('%Y-%m-%d', time.localtime()) + + def _build_path(self, date_str: str) -> str: + return os.path.join(self.log_dir, 'langbot-%s.log' % date_str) + + def shouldRollover(self, record): + # Roll over when the day changes, regardless of file size. + if self._today() != self._current_date: + return True + return super().shouldRollover(record) + + def doRollover(self): + today = self._today() + if today != self._current_date: + # Date changed: point the handler at the new day's file. + # This is a date switch, not a size-based numbered rotation. + if self.stream: + self.stream.close() + self.stream = None + self._current_date = today + self.baseFilename = os.path.abspath(self._build_path(today)) + if not self.delay: + self.stream = self._open() + else: + # Same day, file exceeded maxBytes: numbered rotation. + super().doRollover() + async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.Logger: # Remove all existing loggers @@ -31,8 +92,6 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging. if constants.debug_mode: level = logging.DEBUG - log_file_name = 'data/logs/langbot-%s.log' % time.strftime('%Y-%m-%d', time.localtime()) - qcg_logger = logging.getLogger('langbot') qcg_logger.setLevel(level) @@ -48,12 +107,13 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging. # stream_handler.setFormatter(color_formatter) stream_handler.stream = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1) - # Use RotatingFileHandler to prevent unbounded log file growth - rotating_file_handler = logging.handlers.RotatingFileHandler( - log_file_name, + # Rotate by size within a day and switch files when the date changes, + # so long-running processes still produce a log file for the current day. + rotating_file_handler = DailyGroupedRotatingFileHandler( + LOG_DIR, + max_bytes=LOG_FILE_MAX_BYTES, + backup_count=LOG_FILE_BACKUP_COUNT, encoding='utf-8', - maxBytes=LOG_FILE_MAX_BYTES, - backupCount=LOG_FILE_BACKUP_COUNT, ) log_handlers: list[logging.Handler] = [ diff --git a/src/langbot/pkg/core/migration.py b/src/langbot/pkg/core/migration.py deleted file mode 100644 index a921e6c7a..000000000 --- a/src/langbot/pkg/core/migration.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -import abc -import typing - -from . import app - - -preregistered_migrations: list[typing.Type[Migration]] = [] -"""Currently not supported for extension""" - - -def migration_class(name: str, number: int): - """Register a migration""" - - def decorator(cls: typing.Type[Migration]) -> typing.Type[Migration]: - cls.name = name - cls.number = number - preregistered_migrations.append(cls) - return cls - - return decorator - - -class Migration(abc.ABC): - """A version migration""" - - name: str - - number: int - - ap: app.Application - - def __init__(self, ap: app.Application): - self.ap = ap - - @abc.abstractmethod - async def need_migrate(self) -> bool: - """Determine if the current environment needs to run this migration""" - pass - - @abc.abstractmethod - async def run(self): - """Run migration""" - pass diff --git a/src/langbot/pkg/core/migrations/m001_sensitive_word_migration.py b/src/langbot/pkg/core/migrations/m001_sensitive_word_migration.py deleted file mode 100644 index 35cb076f3..000000000 --- a/src/langbot/pkg/core/migrations/m001_sensitive_word_migration.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -import os - -from .. import migration - - -@migration.migration_class('sensitive-word-migration', 1) -class SensitiveWordMigration(migration.Migration): - """敏感词迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return os.path.exists('data/config/sensitive-words.json') and not os.path.exists( - 'data/metadata/sensitive-words.json' - ) - - async def run(self): - """执行迁移""" - # 移动文件 - os.rename('data/config/sensitive-words.json', 'data/metadata/sensitive-words.json') - - # 重新加载配置 - await self.ap.sensitive_meta.load_config() diff --git a/src/langbot/pkg/core/migrations/m002_openai_config_migration.py b/src/langbot/pkg/core/migrations/m002_openai_config_migration.py deleted file mode 100644 index 9a35370c2..000000000 --- a/src/langbot/pkg/core/migrations/m002_openai_config_migration.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('openai-config-migration', 2) -class OpenAIConfigMigration(migration.Migration): - """OpenAI配置迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return 'openai-config' in self.ap.provider_cfg.data - - async def run(self): - """执行迁移""" - old_openai_config = self.ap.provider_cfg.data['openai-config'].copy() - - if 'keys' not in self.ap.provider_cfg.data: - self.ap.provider_cfg.data['keys'] = {} - - if 'openai' not in self.ap.provider_cfg.data['keys']: - self.ap.provider_cfg.data['keys']['openai'] = [] - - self.ap.provider_cfg.data['keys']['openai'] = old_openai_config['api-keys'] - - self.ap.provider_cfg.data['model'] = old_openai_config['chat-completions-params']['model'] - - del old_openai_config['chat-completions-params']['model'] - - if 'requester' not in self.ap.provider_cfg.data: - self.ap.provider_cfg.data['requester'] = {} - - if 'openai-chat-completions' not in self.ap.provider_cfg.data['requester']: - self.ap.provider_cfg.data['requester']['openai-chat-completions'] = {} - - self.ap.provider_cfg.data['requester']['openai-chat-completions'] = { - 'base-url': old_openai_config['base_url'], - 'args': old_openai_config['chat-completions-params'], - 'timeout': old_openai_config['request-timeout'], - } - - del self.ap.provider_cfg.data['openai-config'] - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m003_anthropic_requester_cfg_completion.py b/src/langbot/pkg/core/migrations/m003_anthropic_requester_cfg_completion.py deleted file mode 100644 index 19369679c..000000000 --- a/src/langbot/pkg/core/migrations/m003_anthropic_requester_cfg_completion.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('anthropic-requester-config-completion', 3) -class AnthropicRequesterConfigCompletionMigration(migration.Migration): - """OpenAI配置迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return ( - 'anthropic-messages' not in self.ap.provider_cfg.data['requester'] - or 'anthropic' not in self.ap.provider_cfg.data['keys'] - ) - - async def run(self): - """执行迁移""" - if 'anthropic-messages' not in self.ap.provider_cfg.data['requester']: - self.ap.provider_cfg.data['requester']['anthropic-messages'] = { - 'base-url': 'https://api.anthropic.com', - 'args': {'max_tokens': 1024}, - 'timeout': 120, - } - - if 'anthropic' not in self.ap.provider_cfg.data['keys']: - self.ap.provider_cfg.data['keys']['anthropic'] = [] - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m004_moonshot_cfg_completion.py b/src/langbot/pkg/core/migrations/m004_moonshot_cfg_completion.py deleted file mode 100644 index de0861598..000000000 --- a/src/langbot/pkg/core/migrations/m004_moonshot_cfg_completion.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('moonshot-config-completion', 4) -class MoonshotConfigCompletionMigration(migration.Migration): - """OpenAI配置迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return ( - 'moonshot-chat-completions' not in self.ap.provider_cfg.data['requester'] - or 'moonshot' not in self.ap.provider_cfg.data['keys'] - ) - - async def run(self): - """执行迁移""" - if 'moonshot-chat-completions' not in self.ap.provider_cfg.data['requester']: - self.ap.provider_cfg.data['requester']['moonshot-chat-completions'] = { - 'base-url': 'https://api.moonshot.cn/v1', - 'args': {}, - 'timeout': 120, - } - - if 'moonshot' not in self.ap.provider_cfg.data['keys']: - self.ap.provider_cfg.data['keys']['moonshot'] = [] - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m005_deepseek_cfg_completion.py b/src/langbot/pkg/core/migrations/m005_deepseek_cfg_completion.py deleted file mode 100644 index d4d82e3f0..000000000 --- a/src/langbot/pkg/core/migrations/m005_deepseek_cfg_completion.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('deepseek-config-completion', 5) -class DeepseekConfigCompletionMigration(migration.Migration): - """OpenAI配置迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return ( - 'deepseek-chat-completions' not in self.ap.provider_cfg.data['requester'] - or 'deepseek' not in self.ap.provider_cfg.data['keys'] - ) - - async def run(self): - """执行迁移""" - if 'deepseek-chat-completions' not in self.ap.provider_cfg.data['requester']: - self.ap.provider_cfg.data['requester']['deepseek-chat-completions'] = { - 'base-url': 'https://api.deepseek.com', - 'args': {}, - 'timeout': 120, - } - - if 'deepseek' not in self.ap.provider_cfg.data['keys']: - self.ap.provider_cfg.data['keys']['deepseek'] = [] - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m006_vision_config.py b/src/langbot/pkg/core/migrations/m006_vision_config.py deleted file mode 100644 index ea824d441..000000000 --- a/src/langbot/pkg/core/migrations/m006_vision_config.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('vision-config', 6) -class VisionConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return 'enable-vision' not in self.ap.provider_cfg.data - - async def run(self): - """执行迁移""" - if 'enable-vision' not in self.ap.provider_cfg.data: - self.ap.provider_cfg.data['enable-vision'] = False - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m007_qcg_center_url.py b/src/langbot/pkg/core/migrations/m007_qcg_center_url.py deleted file mode 100644 index 2783e0794..000000000 --- a/src/langbot/pkg/core/migrations/m007_qcg_center_url.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('qcg-center-url-config', 7) -class QCGCenterURLConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return 'qcg-center-url' not in self.ap.system_cfg.data - - async def run(self): - """执行迁移""" - - if 'qcg-center-url' not in self.ap.system_cfg.data: - self.ap.system_cfg.data['qcg-center-url'] = 'https://api.qchatgpt.rockchin.top/api/v2' - - await self.ap.system_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m008_ad_fixwin_config_migrate.py b/src/langbot/pkg/core/migrations/m008_ad_fixwin_config_migrate.py deleted file mode 100644 index 964e819bd..000000000 --- a/src/langbot/pkg/core/migrations/m008_ad_fixwin_config_migrate.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('ad-fixwin-cfg-migration', 8) -class AdFixwinConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return isinstance(self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default'], int) - - async def run(self): - """执行迁移""" - - for session_name in self.ap.pipeline_cfg.data['rate-limit']['fixwin']: - temp_dict = { - 'window-size': 60, - 'limit': self.ap.pipeline_cfg.data['rate-limit']['fixwin'][session_name], - } - - self.ap.pipeline_cfg.data['rate-limit']['fixwin'][session_name] = temp_dict - - await self.ap.pipeline_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m009_msg_truncator_cfg.py b/src/langbot/pkg/core/migrations/m009_msg_truncator_cfg.py deleted file mode 100644 index 066af126e..000000000 --- a/src/langbot/pkg/core/migrations/m009_msg_truncator_cfg.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('msg-truncator-cfg-migration', 9) -class MsgTruncatorConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return 'msg-truncate' not in self.ap.pipeline_cfg.data - - async def run(self): - """执行迁移""" - - self.ap.pipeline_cfg.data['msg-truncate'] = { - 'method': 'round', - 'round': {'max-round': 10}, - } - - await self.ap.pipeline_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m010_ollama_requester_config.py b/src/langbot/pkg/core/migrations/m010_ollama_requester_config.py deleted file mode 100644 index 8e2e15eb2..000000000 --- a/src/langbot/pkg/core/migrations/m010_ollama_requester_config.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('ollama-requester-config', 10) -class MsgTruncatorConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return 'ollama-chat' not in self.ap.provider_cfg.data['requester'] - - async def run(self): - """执行迁移""" - - self.ap.provider_cfg.data['requester']['ollama-chat'] = { - 'base-url': 'http://127.0.0.1:11434', - 'args': {}, - 'timeout': 600, - } - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m011_command_prefix_config.py b/src/langbot/pkg/core/migrations/m011_command_prefix_config.py deleted file mode 100644 index 6165ae479..000000000 --- a/src/langbot/pkg/core/migrations/m011_command_prefix_config.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('command-prefix-config', 11) -class CommandPrefixConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return 'command-prefix' not in self.ap.command_cfg.data - - async def run(self): - """执行迁移""" - - self.ap.command_cfg.data['command-prefix'] = ['!', '!'] - - await self.ap.command_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m012_runner_config.py b/src/langbot/pkg/core/migrations/m012_runner_config.py deleted file mode 100644 index e7f0e67ae..000000000 --- a/src/langbot/pkg/core/migrations/m012_runner_config.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('runner-config', 12) -class RunnerConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return 'runner' not in self.ap.provider_cfg.data - - async def run(self): - """执行迁移""" - - self.ap.provider_cfg.data['runner'] = 'local-agent' - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m013_http_api_config.py b/src/langbot/pkg/core/migrations/m013_http_api_config.py deleted file mode 100644 index 80e7b74fc..000000000 --- a/src/langbot/pkg/core/migrations/m013_http_api_config.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('http-api-config', 13) -class HttpApiConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return 'http-api' not in self.ap.system_cfg.data or 'persistence' not in self.ap.system_cfg.data - - async def run(self): - """执行迁移""" - - self.ap.system_cfg.data['http-api'] = { - 'enable': True, - 'host': '0.0.0.0', - 'port': 5300, - 'jwt-expire': 604800, - } - - self.ap.system_cfg.data['persistence'] = { - 'sqlite': {'path': 'data/persistence.db'}, - 'use': 'sqlite', - } - - await self.ap.system_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m014_force_delay_config.py b/src/langbot/pkg/core/migrations/m014_force_delay_config.py deleted file mode 100644 index 005a2ca26..000000000 --- a/src/langbot/pkg/core/migrations/m014_force_delay_config.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('force-delay-config', 14) -class ForceDelayConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return isinstance(self.ap.platform_cfg.data['force-delay'], list) - - async def run(self): - """执行迁移""" - - self.ap.platform_cfg.data['force-delay'] = { - 'min': self.ap.platform_cfg.data['force-delay'][0], - 'max': self.ap.platform_cfg.data['force-delay'][1], - } - - await self.ap.platform_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m015_gitee_ai_config.py b/src/langbot/pkg/core/migrations/m015_gitee_ai_config.py deleted file mode 100644 index 7dd9b8531..000000000 --- a/src/langbot/pkg/core/migrations/m015_gitee_ai_config.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('gitee-ai-config', 15) -class GiteeAIConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return ( - 'gitee-ai-chat-completions' not in self.ap.provider_cfg.data['requester'] - or 'gitee-ai' not in self.ap.provider_cfg.data['keys'] - ) - - async def run(self): - """执行迁移""" - self.ap.provider_cfg.data['requester']['gitee-ai-chat-completions'] = { - 'base-url': 'https://ai.gitee.com/v1', - 'args': {}, - 'timeout': 120, - } - - self.ap.provider_cfg.data['keys']['gitee-ai'] = ['XXXXX'] - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m016_dify_service_api.py b/src/langbot/pkg/core/migrations/m016_dify_service_api.py deleted file mode 100644 index e7c4dc6db..000000000 --- a/src/langbot/pkg/core/migrations/m016_dify_service_api.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('dify-service-api-config', 16) -class DifyServiceAPICfgMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return 'dify-service-api' not in self.ap.provider_cfg.data - - async def run(self): - """执行迁移""" - self.ap.provider_cfg.data['dify-service-api'] = { - 'base-url': 'https://api.dify.ai/v1', - 'app-type': 'chat', - 'chat': {'api-key': 'app-1234567890'}, - 'workflow': {'api-key': 'app-1234567890', 'output-key': 'summary'}, - } - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m017_dify_api_timeout_params.py b/src/langbot/pkg/core/migrations/m017_dify_api_timeout_params.py deleted file mode 100644 index 67635fb5d..000000000 --- a/src/langbot/pkg/core/migrations/m017_dify_api_timeout_params.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('dify-api-timeout-params', 17) -class DifyAPITimeoutParamsMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return ( - 'timeout' not in self.ap.provider_cfg.data['dify-service-api']['chat'] - or 'timeout' not in self.ap.provider_cfg.data['dify-service-api']['workflow'] - or 'agent' not in self.ap.provider_cfg.data['dify-service-api'] - ) - - async def run(self): - """执行迁移""" - self.ap.provider_cfg.data['dify-service-api']['chat']['timeout'] = 120 - self.ap.provider_cfg.data['dify-service-api']['workflow']['timeout'] = 120 - self.ap.provider_cfg.data['dify-service-api']['agent'] = { - 'api-key': 'app-1234567890', - 'timeout': 120, - } - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m018_xai_config.py b/src/langbot/pkg/core/migrations/m018_xai_config.py deleted file mode 100644 index db5ed5bfe..000000000 --- a/src/langbot/pkg/core/migrations/m018_xai_config.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('xai-config', 18) -class XaiConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return 'xai-chat-completions' not in self.ap.provider_cfg.data['requester'] - - async def run(self): - """执行迁移""" - self.ap.provider_cfg.data['requester']['xai-chat-completions'] = { - 'base-url': 'https://api.x.ai/v1', - 'args': {}, - 'timeout': 120, - } - self.ap.provider_cfg.data['keys']['xai'] = ['xai-1234567890'] - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m019_zhipuai_config.py b/src/langbot/pkg/core/migrations/m019_zhipuai_config.py deleted file mode 100644 index 081d8dcf0..000000000 --- a/src/langbot/pkg/core/migrations/m019_zhipuai_config.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('zhipuai-config', 19) -class ZhipuaiConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return 'zhipuai-chat-completions' not in self.ap.provider_cfg.data['requester'] - - async def run(self): - """执行迁移""" - self.ap.provider_cfg.data['requester']['zhipuai-chat-completions'] = { - 'base-url': 'https://open.bigmodel.cn/api/paas/v4', - 'args': {}, - 'timeout': 120, - } - self.ap.provider_cfg.data['keys']['zhipuai'] = ['xxxxxxx'] - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m020_wecom_config.py b/src/langbot/pkg/core/migrations/m020_wecom_config.py deleted file mode 100644 index 3e833d3ef..000000000 --- a/src/langbot/pkg/core/migrations/m020_wecom_config.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('wecom-config', 20) -class WecomConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - # for adapter in self.ap.platform_cfg.data['platform-adapters']: - # if adapter['adapter'] == 'wecom': - # return False - - # return True - return False - - async def run(self): - """执行迁移""" - self.ap.platform_cfg.data['platform-adapters'].append( - { - 'adapter': 'wecom', - 'enable': False, - 'host': '0.0.0.0', - 'port': 2290, - 'corpid': '', - 'secret': '', - 'token': '', - 'EncodingAESKey': '', - 'contacts_secret': '', - } - ) - - await self.ap.platform_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m021_lark_config.py b/src/langbot/pkg/core/migrations/m021_lark_config.py deleted file mode 100644 index 04f29db40..000000000 --- a/src/langbot/pkg/core/migrations/m021_lark_config.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('lark-config', 21) -class LarkConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - # for adapter in self.ap.platform_cfg.data['platform-adapters']: - # if adapter['adapter'] == 'lark': - # return False - - # return True - return False - - async def run(self): - """执行迁移""" - self.ap.platform_cfg.data['platform-adapters'].append( - { - 'adapter': 'lark', - 'enable': False, - 'app_id': 'cli_abcdefgh', - 'app_secret': 'XXXXXXXXXX', - 'bot_name': 'LangBot', - 'enable-webhook': False, - 'port': 2285, - 'encrypt-key': 'xxxxxxxxx', - } - ) - - await self.ap.platform_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m022_lmstudio_config.py b/src/langbot/pkg/core/migrations/m022_lmstudio_config.py deleted file mode 100644 index bffc6bb87..000000000 --- a/src/langbot/pkg/core/migrations/m022_lmstudio_config.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('lmstudio-config', 22) -class LmStudioConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - return 'lmstudio-chat-completions' not in self.ap.provider_cfg.data['requester'] - - async def run(self): - """执行迁移""" - self.ap.provider_cfg.data['requester']['lmstudio-chat-completions'] = { - 'base-url': 'http://127.0.0.1:1234/v1', - 'args': {}, - 'timeout': 120, - } - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m023_siliconflow_config.py b/src/langbot/pkg/core/migrations/m023_siliconflow_config.py deleted file mode 100644 index 31b9ee8e0..000000000 --- a/src/langbot/pkg/core/migrations/m023_siliconflow_config.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('siliconflow-config', 23) -class SiliconFlowConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - return 'siliconflow-chat-completions' not in self.ap.provider_cfg.data['requester'] - - async def run(self): - """执行迁移""" - self.ap.provider_cfg.data['keys']['siliconflow'] = ['xxxxxxx'] - - self.ap.provider_cfg.data['requester']['siliconflow-chat-completions'] = { - 'base-url': 'https://api.siliconflow.cn/v1', - 'args': {}, - 'timeout': 120, - } - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m024_discord_config.py b/src/langbot/pkg/core/migrations/m024_discord_config.py deleted file mode 100644 index ebcae2321..000000000 --- a/src/langbot/pkg/core/migrations/m024_discord_config.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('discord-config', 24) -class DiscordConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - # for adapter in self.ap.platform_cfg.data['platform-adapters']: - # if adapter['adapter'] == 'discord': - # return False - - # return True - return False - - async def run(self): - """执行迁移""" - self.ap.platform_cfg.data['platform-adapters'].append( - { - 'adapter': 'discord', - 'enable': False, - 'client_id': '1234567890', - 'token': 'XXXXXXXXXX', - } - ) - - await self.ap.platform_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m025_gewechat_config.py b/src/langbot/pkg/core/migrations/m025_gewechat_config.py deleted file mode 100644 index bb729854c..000000000 --- a/src/langbot/pkg/core/migrations/m025_gewechat_config.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('gewechat-config', 25) -class GewechatConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - # for adapter in self.ap.platform_cfg.data['platform-adapters']: - # if adapter['adapter'] == 'gewechat': - # return False - - # return True - return False - - async def run(self): - """执行迁移""" - self.ap.platform_cfg.data['platform-adapters'].append( - { - 'adapter': 'gewechat', - 'enable': False, - 'gewechat_url': 'http://your-gewechat-server:2531', - 'gewechat_file_url': 'http://your-gewechat-server:2532', - 'port': 2286, - 'callback_url': 'http://your-callback-url:2286/gewechat/callback', - 'app_id': '', - 'token': '', - } - ) - - await self.ap.platform_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m026_qqofficial_config.py b/src/langbot/pkg/core/migrations/m026_qqofficial_config.py deleted file mode 100644 index 906743416..000000000 --- a/src/langbot/pkg/core/migrations/m026_qqofficial_config.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('qqofficial-config', 26) -class QQOfficialConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - # for adapter in self.ap.platform_cfg.data['platform-adapters']: - # if adapter['adapter'] == 'qqofficial': - # return False - - # return True - return False - - async def run(self): - """执行迁移""" - self.ap.platform_cfg.data['platform-adapters'].append( - { - 'adapter': 'qqofficial', - 'enable': False, - 'appid': '', - 'secret': '', - 'port': 2284, - 'token': '', - } - ) - - await self.ap.platform_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m027_wx_official_account_config.py b/src/langbot/pkg/core/migrations/m027_wx_official_account_config.py deleted file mode 100644 index 7c5b0e35a..000000000 --- a/src/langbot/pkg/core/migrations/m027_wx_official_account_config.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('wx-official-account-config', 27) -class WXOfficialAccountConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - # for adapter in self.ap.platform_cfg.data['platform-adapters']: - # if adapter['adapter'] == 'officialaccount': - # return False - - # return True - return False - - async def run(self): - """执行迁移""" - self.ap.platform_cfg.data['platform-adapters'].append( - { - 'adapter': 'officialaccount', - 'enable': False, - 'token': '', - 'EncodingAESKey': '', - 'AppID': '', - 'AppSecret': '', - 'host': '0.0.0.0', - 'port': 2287, - } - ) - - await self.ap.platform_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m028_aliyun_requester_config.py b/src/langbot/pkg/core/migrations/m028_aliyun_requester_config.py deleted file mode 100644 index 8d80727ae..000000000 --- a/src/langbot/pkg/core/migrations/m028_aliyun_requester_config.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('bailian-requester-config', 28) -class BailianRequesterConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - return 'bailian-chat-completions' not in self.ap.provider_cfg.data['requester'] - - async def run(self): - """执行迁移""" - self.ap.provider_cfg.data['keys']['bailian'] = ['sk-xxxxxxx'] - - self.ap.provider_cfg.data['requester']['bailian-chat-completions'] = { - 'base-url': 'https://dashscope.aliyuncs.com/compatible-mode/v1', - 'args': {}, - 'timeout': 120, - } - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m029_dashscope_app_api_config.py b/src/langbot/pkg/core/migrations/m029_dashscope_app_api_config.py deleted file mode 100644 index 5a61fe0d1..000000000 --- a/src/langbot/pkg/core/migrations/m029_dashscope_app_api_config.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('dashscope-app-api-config', 29) -class DashscopeAppAPICfgMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return 'dashscope-app-api' not in self.ap.provider_cfg.data - - async def run(self): - """执行迁移""" - self.ap.provider_cfg.data['dashscope-app-api'] = { - 'app-type': 'agent', - 'api-key': 'sk-1234567890', - 'agent': {'app-id': 'Your_app_id', 'references_quote': '参考资料来自:'}, - 'workflow': { - 'app-id': 'Your_app_id', - 'references_quote': '参考资料来自:', - 'biz_params': {'city': '北京', 'date': '2023-08-10'}, - }, - } - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m030_lark_config_cmpl.py b/src/langbot/pkg/core/migrations/m030_lark_config_cmpl.py deleted file mode 100644 index 37e8fabe4..000000000 --- a/src/langbot/pkg/core/migrations/m030_lark_config_cmpl.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('lark-config-cmpl', 30) -class LarkConfigCmplMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - for adapter in self.ap.platform_cfg.data['platform-adapters']: - if adapter['adapter'] == 'lark': - if 'enable-webhook' not in adapter: - return True - - return False - - async def run(self): - """执行迁移""" - for adapter in self.ap.platform_cfg.data['platform-adapters']: - if adapter['adapter'] == 'lark': - if 'enable-webhook' not in adapter: - adapter['enable-webhook'] = False - if 'port' not in adapter: - adapter['port'] = 2285 - if 'encrypt-key' not in adapter: - adapter['encrypt-key'] = 'xxxxxxxxx' - - await self.ap.platform_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m031_dingtalk_config.py b/src/langbot/pkg/core/migrations/m031_dingtalk_config.py deleted file mode 100644 index 22ba0bbf2..000000000 --- a/src/langbot/pkg/core/migrations/m031_dingtalk_config.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('dingtalk-config', 31) -class DingTalkConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - # for adapter in self.ap.platform_cfg.data['platform-adapters']: - # if adapter['adapter'] == 'dingtalk': - # return False - - # return True - return False - - async def run(self): - """执行迁移""" - self.ap.platform_cfg.data['platform-adapters'].append( - { - 'adapter': 'dingtalk', - 'enable': False, - 'client_id': '', - 'client_secret': '', - 'robot_code': '', - 'robot_name': '', - } - ) - - await self.ap.platform_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m032_volcark_config.py b/src/langbot/pkg/core/migrations/m032_volcark_config.py deleted file mode 100644 index ae8feb522..000000000 --- a/src/langbot/pkg/core/migrations/m032_volcark_config.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('volcark-requester-config', 32) -class VolcArkRequesterConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - return 'volcark-chat-completions' not in self.ap.provider_cfg.data['requester'] - - async def run(self): - """执行迁移""" - self.ap.provider_cfg.data['keys']['volcark'] = ['xxxxxxxx'] - - self.ap.provider_cfg.data['requester']['volcark-chat-completions'] = { - 'base-url': 'https://ark.cn-beijing.volces.com/api/v3', - 'args': {}, - 'timeout': 120, - } - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m033_dify_thinking_config.py b/src/langbot/pkg/core/migrations/m033_dify_thinking_config.py deleted file mode 100644 index 7269765a4..000000000 --- a/src/langbot/pkg/core/migrations/m033_dify_thinking_config.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('dify-thinking-config', 33) -class DifyThinkingConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - if 'options' not in self.ap.provider_cfg.data['dify-service-api']: - return True - - if 'convert-thinking-tips' not in self.ap.provider_cfg.data['dify-service-api']['options']: - return True - - return False - - async def run(self): - """执行迁移""" - self.ap.provider_cfg.data['dify-service-api']['options'] = {'convert-thinking-tips': 'plain'} - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m034_gewechat_file_url_config.py b/src/langbot/pkg/core/migrations/m034_gewechat_file_url_config.py deleted file mode 100644 index 512b75b1b..000000000 --- a/src/langbot/pkg/core/migrations/m034_gewechat_file_url_config.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from urllib.parse import urlparse - -from .. import migration - - -@migration.migration_class('gewechat-file-url-config', 34) -class GewechatFileUrlConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - for adapter in self.ap.platform_cfg.data['platform-adapters']: - if adapter['adapter'] == 'gewechat': - if 'gewechat_file_url' not in adapter: - return True - return False - - async def run(self): - """执行迁移""" - for adapter in self.ap.platform_cfg.data['platform-adapters']: - if adapter['adapter'] == 'gewechat': - if 'gewechat_file_url' not in adapter: - parsed_url = urlparse(adapter['gewechat_url']) - adapter['gewechat_file_url'] = f'{parsed_url.scheme}://{parsed_url.hostname}:2532' - - await self.ap.platform_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m035_wxoa_mode.py b/src/langbot/pkg/core/migrations/m035_wxoa_mode.py deleted file mode 100644 index 6b675e30b..000000000 --- a/src/langbot/pkg/core/migrations/m035_wxoa_mode.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('wxoa-mode', 35) -class WxoaModeMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - for adapter in self.ap.platform_cfg.data['platform-adapters']: - if adapter['adapter'] == 'officialaccount': - if 'Mode' not in adapter: - return True - return False - - async def run(self): - """执行迁移""" - for adapter in self.ap.platform_cfg.data['platform-adapters']: - if adapter['adapter'] == 'officialaccount': - if 'Mode' not in adapter: - adapter['Mode'] = 'drop' - - await self.ap.platform_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m036_wxoa_loading_message.py b/src/langbot/pkg/core/migrations/m036_wxoa_loading_message.py deleted file mode 100644 index 29ecba201..000000000 --- a/src/langbot/pkg/core/migrations/m036_wxoa_loading_message.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('wxoa-loading-message', 36) -class WxoaLoadingMessageMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - for adapter in self.ap.platform_cfg.data['platform-adapters']: - if adapter['adapter'] == 'officialaccount': - if 'LoadingMessage' not in adapter: - return True - return False - - async def run(self): - """执行迁移""" - for adapter in self.ap.platform_cfg.data['platform-adapters']: - if adapter['adapter'] == 'officialaccount': - if 'LoadingMessage' not in adapter: - adapter['LoadingMessage'] = 'AI正在思考中,请发送任意内容获取回复。' - - await self.ap.platform_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m037_mcp_config.py b/src/langbot/pkg/core/migrations/m037_mcp_config.py deleted file mode 100644 index 3752193e1..000000000 --- a/src/langbot/pkg/core/migrations/m037_mcp_config.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('mcp-config', 37) -class MCPConfigMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return 'mcp' not in self.ap.provider_cfg.data - - async def run(self): - """执行迁移""" - self.ap.provider_cfg.data['mcp'] = {'servers': []} - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m038_tg_dingtalk_markdown.py b/src/langbot/pkg/core/migrations/m038_tg_dingtalk_markdown.py deleted file mode 100644 index c0a85a44d..000000000 --- a/src/langbot/pkg/core/migrations/m038_tg_dingtalk_markdown.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('tg-dingtalk-markdown', 38) -class TgDingtalkMarkdownMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - - for adapter in self.ap.platform_cfg.data['platform-adapters']: - if adapter['adapter'] in ['dingtalk', 'telegram']: - if 'markdown_card' not in adapter: - return True - return False - - async def run(self): - """执行迁移""" - for adapter in self.ap.platform_cfg.data['platform-adapters']: - if adapter['adapter'] in ['dingtalk', 'telegram']: - if 'markdown_card' not in adapter: - adapter['markdown_card'] = False - await self.ap.platform_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m039_modelscope_cfg_completion.py b/src/langbot/pkg/core/migrations/m039_modelscope_cfg_completion.py deleted file mode 100644 index 9eec0344e..000000000 --- a/src/langbot/pkg/core/migrations/m039_modelscope_cfg_completion.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('modelscope-config-completion', 39) -class ModelScopeConfigCompletionMigration(migration.Migration): - """ModelScope配置迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return ( - 'modelscope-chat-completions' not in self.ap.provider_cfg.data['requester'] - or 'modelscope' not in self.ap.provider_cfg.data['keys'] - ) - - async def run(self): - """执行迁移""" - if 'modelscope-chat-completions' not in self.ap.provider_cfg.data['requester']: - self.ap.provider_cfg.data['requester']['modelscope-chat-completions'] = { - 'base-url': 'https://api-inference.modelscope.cn/v1', - 'args': {}, - 'timeout': 120, - } - - if 'modelscope' not in self.ap.provider_cfg.data['keys']: - self.ap.provider_cfg.data['keys']['modelscope'] = [] - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m040_ppio_config.py b/src/langbot/pkg/core/migrations/m040_ppio_config.py deleted file mode 100644 index d4d82b983..000000000 --- a/src/langbot/pkg/core/migrations/m040_ppio_config.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('ppio-config', 40) -class PPIOConfigMigration(migration.Migration): - """PPIO配置迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return ( - 'ppio-chat-completions' not in self.ap.provider_cfg.data['requester'] - or 'ppio' not in self.ap.provider_cfg.data['keys'] - ) - - async def run(self): - """执行迁移""" - if 'ppio-chat-completions' not in self.ap.provider_cfg.data['requester']: - self.ap.provider_cfg.data['requester']['ppio-chat-completions'] = { - 'base-url': 'https://api.ppinfra.com/v3/openai', - 'args': {}, - 'timeout': 120, - } - - if 'ppio' not in self.ap.provider_cfg.data['keys']: - self.ap.provider_cfg.data['keys']['ppio'] = [] - - await self.ap.provider_cfg.dump_config() diff --git a/src/langbot/pkg/core/migrations/m041_dingtalk_card_autolayout_config.py b/src/langbot/pkg/core/migrations/m041_dingtalk_card_autolayout_config.py deleted file mode 100644 index 58968d4d9..000000000 --- a/src/langbot/pkg/core/migrations/m041_dingtalk_card_autolayout_config.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from .. import migration - - -@migration.migration_class('dingtalk_card_auto_layout', 41) -class DingTalkCardAutoLayoutMigration(migration.Migration): - """迁移""" - - async def need_migrate(self) -> bool: - """判断当前环境是否需要运行此迁移""" - return True - - async def run(self): - """执行迁移""" - self.ap.platform_cfg.data['platform-adapters']['app']['dingtalk']['card_auto_layout'] = False - await self.ap.platform_cfg.dump_config() diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index 3bb5ffd7a..a8d53b7b3 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -6,6 +6,7 @@ from .. import stage, app from ...utils import version, proxy from ...pipeline import pool, controller, pipelinemgr from ...pipeline import aggregator as message_aggregator +from ...box import service as box_service from ...plugin import connector as plugin_connector from ...command import cmdmgr from ...provider.session import sessionmgr as llm_session_mgr @@ -28,6 +29,8 @@ from ...api.http.service import mcp as mcp_service from ...api.http.service import apikey as apikey_service from ...api.http.service import webhook as webhook_service from ...api.http.service import monitoring as monitoring_service +from ...api.http.service import skill as skill_service +from ...skill import manager as skill_mgr from ...api.http.service import maintenance as maintenance_service from ...discover import engine as discover_engine from ...storage import mgr as storagemgr @@ -86,6 +89,9 @@ class BuildAppStage(stage.BootingStage): webhook_service_inst = webhook_service.WebhookService(ap) ap.webhook_service = webhook_service_inst + skill_service_inst = skill_service.SkillService(ap) + ap.skill_service = skill_service_inst + proxy_mgr = proxy.ProxyManager(ap) await proxy_mgr.initialize() ap.proxy_mgr = proxy_mgr @@ -129,6 +135,10 @@ class BuildAppStage(stage.BootingStage): await llm_session_mgr_inst.initialize() ap.sess_mgr = llm_session_mgr_inst + box_service_inst = box_service.BoxService(ap) + await box_service_inst.initialize() + ap.box_service = box_service_inst + llm_tool_mgr_inst = llm_tool_mgr.ToolManager(ap) await llm_tool_mgr_inst.initialize() ap.tool_mgr = llm_tool_mgr_inst @@ -149,6 +159,11 @@ class BuildAppStage(stage.BootingStage): msg_aggregator_inst = message_aggregator.MessageAggregator(ap) ap.msg_aggregator = msg_aggregator_inst + # Initialize skill manager + skill_mgr_inst = skill_mgr.SkillManager(ap) + await skill_mgr_inst.initialize() + ap.skill_mgr = skill_mgr_inst + rag_mgr_inst = rag_mgr.RAGManager(ap) await rag_mgr_inst.initialize() ap.rag_mgr = rag_mgr_inst diff --git a/src/langbot/pkg/core/stages/load_config.py b/src/langbot/pkg/core/stages/load_config.py index 26f4a9e19..6fc890f10 100644 --- a/src/langbot/pkg/core/stages/load_config.py +++ b/src/langbot/pkg/core/stages/load_config.py @@ -202,6 +202,16 @@ class LoadConfigStage(stage.BootingStage): constants.instance_id = new_id constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community') + # Instance creation timestamp: sourced from data/labels/instance_id.json. + # Instances created before this field existed (or supplied via + # system.instance_id) won't have it, so backfill with the current time + # and persist it via the dump below — from then on it stays stable. + instance_create_ts = ap.instance_id.data.get('instance_create_ts', 0) + if not isinstance(instance_create_ts, int) or instance_create_ts <= 0: + instance_create_ts = int(time.time()) + ap.instance_id.data['instance_create_ts'] = instance_create_ts + constants.instance_create_ts = instance_create_ts + print(f'LangBot instance id: {constants.instance_id}') print(f'LangBot edition: {constants.edition}') diff --git a/src/langbot/pkg/core/stages/migrate.py b/src/langbot/pkg/core/stages/migrate.py deleted file mode 100644 index 229e00609..000000000 --- a/src/langbot/pkg/core/stages/migrate.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - - -from .. import stage, app -from .. import migration -from ...utils import importutil -from .. import migrations - -importutil.import_modules_in_pkg(migrations) - - -@stage.stage_class('MigrationStage') -class MigrationStage(stage.BootingStage): - """Migration stage - - These migrations are legacy, only performed in version 3.x - """ - - async def run(self, ap: app.Application): - """Run migration""" - - if any( - [ - ap.command_cfg is None, - ap.pipeline_cfg is None, - ap.platform_cfg is None, - ap.provider_cfg is None, - ap.system_cfg is None, - ] - ): # only run migration when version is 3.x - return - - migrations = migration.preregistered_migrations - - # Sort by migration number - migrations.sort(key=lambda x: x.number) - - for migration_cls in migrations: - migration_instance = migration_cls(ap) - - if await migration_instance.need_migrate(): - await migration_instance.run() - print(f'Migration {migration_instance.name} executed') diff --git a/src/langbot/pkg/entity/persistence/bot.py b/src/langbot/pkg/entity/persistence/bot.py index c3fa295f7..9043b7560 100644 --- a/src/langbot/pkg/entity/persistence/bot.py +++ b/src/langbot/pkg/entity/persistence/bot.py @@ -3,6 +3,20 @@ import sqlalchemy from .base import Base +class BotAdmin(Base): + """Bot admin — a launcher that has admin privilege for a specific bot's commands""" + + __tablename__ = 'bot_admins' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + launcher_type = sqlalchemy.Column(sqlalchemy.String(64), nullable=False) + launcher_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + + __table_args__ = (sqlalchemy.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),) + + class Bot(Base): """Bot""" diff --git a/src/langbot/pkg/entity/persistence/mcp.py b/src/langbot/pkg/entity/persistence/mcp.py index e9eedbdbb..983fdce53 100644 --- a/src/langbot/pkg/entity/persistence/mcp.py +++ b/src/langbot/pkg/entity/persistence/mcp.py @@ -9,8 +9,12 @@ class MCPServer(Base): uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) - mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http + mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, remote (legacy: sse, http) extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) + # Markdown documentation captured from LangBot Space at install time so the + # detail page can show docs even when the server is offline / has no tools. + # Empty string for manually-created servers that have no marketplace README. + readme = sqlalchemy.Column(sqlalchemy.Text, nullable=False, server_default='', default='') created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, diff --git a/src/langbot/pkg/entity/persistence/model.py b/src/langbot/pkg/entity/persistence/model.py index 3c96acd73..5b5f1fe2f 100644 --- a/src/langbot/pkg/entity/persistence/model.py +++ b/src/langbot/pkg/entity/persistence/model.py @@ -31,6 +31,7 @@ class LLMModel(Base): name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[]) + context_length = sqlalchemy.Column(sqlalchemy.Integer, nullable=True) extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) diff --git a/src/langbot/pkg/entity/persistence/monitoring.py b/src/langbot/pkg/entity/persistence/monitoring.py index 01e4fdd39..f594b8187 100644 --- a/src/langbot/pkg/entity/persistence/monitoring.py +++ b/src/langbot/pkg/entity/persistence/monitoring.py @@ -49,6 +49,28 @@ class MonitoringLLMCall(Base): message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID +class MonitoringToolCall(Base): + """Tool call records""" + + __tablename__ = 'monitoring_tool_calls' + + id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) + timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True) + tool_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + tool_source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # native, plugin, mcp, skill + duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # milliseconds + status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + arguments = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + result = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + + class MonitoringSession(Base): """Session tracking records""" diff --git a/src/langbot/pkg/entity/persistence/pipeline.py b/src/langbot/pkg/entity/persistence/pipeline.py index b3a4b7fe0..d74cf78ee 100644 --- a/src/langbot/pkg/entity/persistence/pipeline.py +++ b/src/langbot/pkg/entity/persistence/pipeline.py @@ -26,7 +26,14 @@ class LegacyPipeline(Base): extensions_preferences = sqlalchemy.Column( sqlalchemy.JSON, nullable=False, - default={'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': []}, + default={ + 'enable_all_plugins': True, + 'enable_all_mcp_servers': True, + 'plugins': [], + 'mcp_servers': [], + 'mcp_resources': [], + 'mcp_resource_agent_read_enabled': True, + }, ) diff --git a/src/langbot/pkg/persistence/alembic/versions/0004_add_mcp_readme.py b/src/langbot/pkg/persistence/alembic/versions/0004_add_mcp_readme.py new file mode 100644 index 000000000..c845ddd3d --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0004_add_mcp_readme.py @@ -0,0 +1,34 @@ +"""add readme column to mcp_servers + +Revision ID: 0004_add_mcp_readme +Revises: 0003_add_rerank_models +Create Date: 2026-06-06 +""" + +import sqlalchemy as sa +from alembic import op + +revision = '0004_add_mcp_readme' +down_revision = '0003_add_rerank_models' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add ``readme`` to mcp_servers if the table exists and the column is missing + # (the table may have been created by create_all() with the column already + # present on fresh installs, so guard against duplicate-add). + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'mcp_servers' not in inspector.get_table_names(): + return + columns = {col['name'] for col in inspector.get_columns('mcp_servers')} + if 'readme' not in columns: + op.add_column( + 'mcp_servers', + sa.Column('readme', sa.Text(), nullable=False, server_default=''), + ) + + +def downgrade() -> None: + op.drop_column('mcp_servers', 'readme') diff --git a/src/langbot/pkg/persistence/alembic/versions/0005_add_llm_context_length.py b/src/langbot/pkg/persistence/alembic/versions/0005_add_llm_context_length.py new file mode 100644 index 000000000..20a9d71e8 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0005_add_llm_context_length.py @@ -0,0 +1,39 @@ +"""add llm model context length + +Revision ID: 0005_add_llm_context_length +Revises: 0004_add_mcp_readme +Create Date: 2026-06-07 +""" + +import sqlalchemy as sa +from alembic import op + +revision = '0005_add_llm_context_length' +down_revision = '0004_add_mcp_readme' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add ``context_length`` to llm_models if the table exists and the column is + # missing. The table may have been created by create_all() with the column + # already present on fresh installs, so guard against duplicate-add; it may + # also be absent entirely (e.g. migrating a truly empty DB), so guard against + # a missing table too. + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'llm_models' not in inspector.get_table_names(): + return + columns = {column['name'] for column in inspector.get_columns('llm_models')} + if 'context_length' not in columns: + op.add_column('llm_models', sa.Column('context_length', sa.Integer(), nullable=True)) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'llm_models' not in inspector.get_table_names(): + return + columns = {column['name'] for column in inspector.get_columns('llm_models')} + if 'context_length' in columns: + op.drop_column('llm_models', 'context_length') diff --git a/src/langbot/pkg/persistence/alembic/versions/0006_normalize_mcp_remote_mode.py b/src/langbot/pkg/persistence/alembic/versions/0006_normalize_mcp_remote_mode.py new file mode 100644 index 000000000..61a2669e8 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0006_normalize_mcp_remote_mode.py @@ -0,0 +1,47 @@ +"""normalize mcp_servers transport mode to local/remote + +The MCP transport selection for servers LangBot connects to was simplified +from three persisted modes (``stdio`` / ``sse`` / ``http``) down to two: +``stdio`` (local, Box-sandboxed) and ``remote`` (the runtime auto-detects +Streamable HTTP vs. legacy SSE from the URL). This migration rewrites any +existing ``sse`` / ``http`` rows to ``remote`` so the stored value matches the +new two-option UI. The connection args (url / headers / timeout / +ssereadtimeout) live in ``extra_args`` and are left untouched — the +auto-detecting remote transport consumes them regardless. + +Revision ID: 0006_normalize_mcp_remote_mode +Revises: 0005_add_llm_context_length +Create Date: 2026-06-21 +""" + +import sqlalchemy as sa +from alembic import op + +revision = '0006_normalize_mcp_remote_mode' +down_revision = '0005_add_llm_context_length' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Idempotent data migration: collapse legacy remote transports into the + # unified ``remote`` mode. Guard against the table being absent (truly empty + # DB migrated before create_all()). + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'mcp_servers' not in inspector.get_table_names(): + return + conn.execute(sa.text("UPDATE mcp_servers SET mode = 'remote' WHERE mode IN ('sse', 'http')")) + + +def downgrade() -> None: + # The legacy distinction between ``sse`` and ``http`` cannot be recovered + # from ``remote`` alone (the transport is auto-detected at runtime, not + # stored). Map everything that is not ``stdio`` back to ``http`` as a + # best-effort reversal — both legacy modes still route correctly in the + # backend lifecycle dispatch. + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'mcp_servers' not in inspector.get_table_names(): + return + conn.execute(sa.text("UPDATE mcp_servers SET mode = 'http' WHERE mode = 'remote'")) diff --git a/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py b/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py new file mode 100644 index 000000000..a13f2caa6 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py @@ -0,0 +1,84 @@ +"""add bot_admins table and migrate config admins + +Revision ID: 0007_add_bot_admins +Revises: 0006_normalize_mcp_remote_mode +Create Date: 2026-06-26 +""" + +import sqlalchemy as sa +from alembic import op + +revision = '0007_add_bot_admins' +down_revision = '0006_normalize_mcp_remote_mode' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + if 'bot_admins' in sa.inspect(conn).get_table_names(): + return + op.create_table( + 'bot_admins', + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column('bot_uuid', sa.String(255), nullable=False), + sa.Column('launcher_type', sa.String(64), nullable=False), + sa.Column('launcher_id', sa.String(255), nullable=False), + sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'), + ) + + # Migrate old config-based admins into the first bot (best-effort) + inspector = sa.inspect(conn) + tables = inspector.get_table_names() + + if 'bots' not in tables: + return + + # Read the first bot uuid + row = conn.execute(sa.text('SELECT uuid FROM bots ORDER BY created_at LIMIT 1')).first() + if row is None: + return + first_bot_uuid = row[0] + + # Read instance_config metadata key that holds the admins list + if 'metadata' not in tables: + return + meta_row = conn.execute(sa.text("SELECT value FROM metadata WHERE key = 'instance_config'")).first() + if meta_row is None: + return + + import json + + try: + cfg = json.loads(meta_row[0]) + except Exception: + return + + admins = cfg.get('admins', []) + for entry in admins: + parts = entry.split('_', 1) + if len(parts) != 2: + continue + launcher_type, launcher_id = parts + try: + conn.execute( + sa.text( + 'INSERT OR IGNORE INTO bot_admins (bot_uuid, launcher_type, launcher_id) VALUES (:bu, :lt, :li)' + ), + {'bu': first_bot_uuid, 'lt': launcher_type, 'li': launcher_id}, + ) + except Exception: + pass + + # Remove admins key from stored config + if 'admins' in cfg: + del cfg['admins'] + conn.execute( + sa.text("UPDATE metadata SET value = :v WHERE key = 'instance_config'"), + {'v': json.dumps(cfg)}, + ) + + +def downgrade() -> None: + op.drop_table('bot_admins') diff --git a/src/langbot/pkg/persistence/alembic/versions/0008_add_mcp_resource_preferences.py b/src/langbot/pkg/persistence/alembic/versions/0008_add_mcp_resource_preferences.py new file mode 100644 index 000000000..9b92db97d --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0008_add_mcp_resource_preferences.py @@ -0,0 +1,95 @@ +"""add mcp resource preferences to pipelines + +Revision ID: 0008_mcp_resource_prefs +Revises: 0007_add_bot_admins +Create Date: 2026-06-30 +""" + +from __future__ import annotations + +import json +from typing import Any + +import sqlalchemy as sa +from alembic import op + +revision = '0008_mcp_resource_prefs' +down_revision = '0007_add_bot_admins' +branch_labels = None +depends_on = None + + +_PIPELINE_TABLE = sa.table( + 'legacy_pipelines', + sa.column('uuid', sa.String(255)), + sa.column('extensions_preferences', sa.JSON()), +) + + +def _has_extensions_preferences_table(conn: sa.Connection) -> bool: + inspector = sa.inspect(conn) + if 'legacy_pipelines' not in inspector.get_table_names(): + return False + columns = {column['name'] for column in inspector.get_columns('legacy_pipelines')} + return 'extensions_preferences' in columns + + +def _decode_preferences(value: Any) -> dict[str, Any]: + if value is None: + return {} + if isinstance(value, dict): + return dict(value) + if isinstance(value, str): + try: + decoded = json.loads(value) + except json.JSONDecodeError: + return {} + if isinstance(decoded, dict): + return decoded + return {} + + +def _update_preferences(conn: sa.Connection, uuid: str, preferences: dict[str, Any]) -> None: + conn.execute( + _PIPELINE_TABLE.update().where(_PIPELINE_TABLE.c.uuid == uuid).values(extensions_preferences=preferences) + ) + + +def upgrade() -> None: + conn = op.get_bind() + if not _has_extensions_preferences_table(conn): + return + + rows = conn.execute(sa.select(_PIPELINE_TABLE.c.uuid, _PIPELINE_TABLE.c.extensions_preferences)).all() + for uuid, raw_preferences in rows: + preferences = _decode_preferences(raw_preferences) + changed = False + + if 'mcp_resources' not in preferences: + preferences['mcp_resources'] = [] + changed = True + if 'mcp_resource_agent_read_enabled' not in preferences: + preferences['mcp_resource_agent_read_enabled'] = True + changed = True + + if changed: + _update_preferences(conn, uuid, preferences) + + +def downgrade() -> None: + conn = op.get_bind() + if not _has_extensions_preferences_table(conn): + return + + rows = conn.execute(sa.select(_PIPELINE_TABLE.c.uuid, _PIPELINE_TABLE.c.extensions_preferences)).all() + for uuid, raw_preferences in rows: + preferences = _decode_preferences(raw_preferences) + changed = False + + for key in ('mcp_resources', 'mcp_resource_agent_read_enabled'): + if key in preferences: + preferences.pop(key) + changed = True + + if changed: + _update_preferences(conn, uuid, preferences) diff --git a/src/langbot/pkg/persistence/migrations/README.md b/src/langbot/pkg/persistence/migrations/README.md new file mode 100644 index 000000000..8f62f1d26 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/README.md @@ -0,0 +1,36 @@ +# Legacy migrations (DEPRECATED — do not add new files here) + +This directory holds the **legacy 3.x database migration system** +(`DBMigration` subclasses in `dbmXXX_*.py`, registered via +`@migration.migration_class(N)` and run from `pkg/persistence/mgr.py`). + +**This system is frozen. Do not add new `dbmXXX_*.py` migrations.** + +The chain is capped at version 25 (`required_database_version = 25` in +`pkg/utils/constants.py`). These files exist only to upgrade pre-existing +3.x databases up to the Alembic baseline (`0001_baseline`). Removing them +would break in-place upgrades from old installations, so they are kept +read-only. + +## All new schema changes use Alembic + +Migrations now live in `pkg/persistence/alembic/versions/`. To create one: + +```bash +uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change" +``` + +(requires `data/config.yaml` to exist). Review and edit the generated +script before committing — Alembic migrations run automatically on startup +and must be idempotent and guard against missing tables (the test suite +runs them against empty databases). + +### Rules for Alembic revision ids + +- Keep the revision id **≤ 32 characters** — PostgreSQL stores + `alembic_version.version_num` as `varchar(32)` and will raise + `StringDataRightTruncationError` on overflow. +- Guard every `op` call against a missing table / missing column + (`inspector.get_table_names()` / `inspector.get_columns()`); fresh + installs create the schema via `create_all()` and stamp the baseline, + so migrations may run against tables that already match or do not exist. diff --git a/src/langbot/pkg/persistence/migrations/dbm026_monitoring_tool_calls.py b/src/langbot/pkg/persistence/migrations/dbm026_monitoring_tool_calls.py new file mode 100644 index 000000000..8bd71b10c --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm026_monitoring_tool_calls.py @@ -0,0 +1,17 @@ +from langbot.pkg.entity.persistence import monitoring as persistence_monitoring +from .. import migration + + +@migration.migration_class(26) +class DBMigrateMonitoringToolCalls(migration.DBMigration): + """Add monitoring_tool_calls table""" + + async def upgrade(self): + """Upgrade""" + async with self.ap.persistence_mgr.get_db_engine().begin() as conn: + await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.create, checkfirst=True) + + async def downgrade(self): + """Downgrade""" + async with self.ap.persistence_mgr.get_db_engine().begin() as conn: + await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.drop, checkfirst=True) diff --git a/src/langbot/pkg/pipeline/monitoring_helper.py b/src/langbot/pkg/pipeline/monitoring_helper.py index 19467cc84..a3a9654bc 100644 --- a/src/langbot/pkg/pipeline/monitoring_helper.py +++ b/src/langbot/pkg/pipeline/monitoring_helper.py @@ -32,7 +32,7 @@ class MonitoringHelper: """Record the start of query processing, returns message_id""" try: # Check if session exists, if not, record session start - session_id = f'{query.launcher_type}_{query.launcher_id}' + session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}' # Get sender name from message event sender_name = None @@ -137,7 +137,7 @@ class MonitoringHelper: ): """Record bot response message to monitoring""" try: - session_id = f'{query.launcher_type}_{query.launcher_id}' + session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}' # Get sender name from message event sender_name = None @@ -202,7 +202,7 @@ class MonitoringHelper: ) -> str: """Record query processing error, returns message_id""" try: - session_id = f'{query.launcher_type}_{query.launcher_id}' + session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}' # Get sender name from message event sender_name = None @@ -268,7 +268,7 @@ class MonitoringHelper: ): """Record LLM call""" try: - session_id = f'{query.launcher_type}_{query.launcher_id}' + session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}' await ap.monitoring_service.record_llm_call( bot_id=bot_id, diff --git a/src/langbot/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py index 6f917b2d1..ef189beaf 100644 --- a/src/langbot/pkg/pipeline/pipelinemgr.py +++ b/src/langbot/pkg/pipeline/pipelinemgr.py @@ -96,6 +96,15 @@ class RuntimePipeline: extensions_prefs = pipeline_entity.extensions_preferences or {} self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True) self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True) + local_agent_config = (pipeline_entity.config or {}).get('ai', {}).get('local-agent', {}) + self.mcp_resource_attachments = local_agent_config.get( + 'mcp-resources', + extensions_prefs.get('mcp_resources', []), + ) + self.mcp_resource_agent_read_enabled = local_agent_config.get( + 'mcp-resource-agent-read-enabled', + extensions_prefs.get('mcp_resource_agent_read_enabled', True), + ) if self.enable_all_plugins: # None indicates to use all available plugins @@ -116,6 +125,8 @@ class RuntimePipeline: # Store bound plugins and MCP servers in query for filtering query.variables['_pipeline_bound_plugins'] = self.bound_plugins query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers + query.variables['_pipeline_mcp_resource_attachments'] = self.mcp_resource_attachments + query.variables['_pipeline_mcp_resource_agent_read_enabled'] = self.mcp_resource_agent_read_enabled # Record query start for monitoring try: @@ -178,7 +189,7 @@ class RuntimePipeline: bot_name = query.variables.get('_monitoring_bot_name', 'Unknown') pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown') message_id = query.variables.get('_monitoring_message_id', '') - session_id = f'{query.launcher_type}_{query.launcher_id}' + session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}' # Update message status to error if message_id: diff --git a/src/langbot/pkg/pipeline/plugin_diagnostics.py b/src/langbot/pkg/pipeline/plugin_diagnostics.py new file mode 100644 index 000000000..3e1195cf2 --- /dev/null +++ b/src/langbot/pkg/pipeline/plugin_diagnostics.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import traceback +import weakref +from dataclasses import dataclass, field +from typing import Any + +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.entities.builtin.platform.message as platform_message + + +@dataclass(frozen=True) +class PluginResponseSource: + plugin: dict[str, str] + event_name: str | None = None + is_approximate: bool = False + + +@dataclass +class QueryDiagnosticState: + pending_by_chain_id: dict[int, list[PluginResponseSource]] = field(default_factory=dict) + by_response_index: dict[int, list[PluginResponseSource]] = field(default_factory=dict) + finalizer: weakref.finalize | None = None + + +_QUERY_STATES: dict[int, QueryDiagnosticState] = {} + + +def record_plugin_response_source( + query: pipeline_query.Query, + response_index: int, + response_sources: list[dict[str, Any]] | None, + emitted_plugins: list[dict[str, Any]] | None = None, + event_name: str | None = None, +) -> None: + plugin_sources = _build_plugin_sources(response_sources, emitted_plugins, event_name) + if not plugin_sources: + return + state = _get_or_create_query_state(query) + state.by_response_index[response_index] = plugin_sources + + +def record_last_plugin_response_source( + query: pipeline_query.Query, + response_sources: list[dict[str, Any]] | None, + emitted_plugins: list[dict[str, Any]] | None = None, + event_name: str | None = None, +) -> None: + record_plugin_response_source( + query, + len(query.resp_message_chain) - 1, + response_sources, + emitted_plugins, + event_name, + ) + + +def record_pending_plugin_response_source( + query: pipeline_query.Query, + message_chain: platform_message.MessageChain, + response_sources: list[dict[str, Any]] | None, + emitted_plugins: list[dict[str, Any]] | None = None, + event_name: str | None = None, +) -> None: + plugin_sources = _build_plugin_sources(response_sources, emitted_plugins, event_name) + if not plugin_sources: + return + state = _get_or_create_query_state(query) + state.pending_by_chain_id[id(message_chain)] = plugin_sources + + +def consume_pending_plugin_response_source( + query: pipeline_query.Query, + message_chain: platform_message.MessageChain, + response_index: int, +) -> None: + state = _get_query_state(query) + if state is None: + return + source = state.pending_by_chain_id.pop(id(message_chain), None) + if source is None: + return + state.by_response_index[response_index] = source + + +def clear_response_source(query: pipeline_query.Query, response_index: int) -> None: + state = _get_query_state(query) + if state is None: + return + state.by_response_index.pop(response_index, None) + _discard_query_state_if_empty(query) + + +async def notify_response_delivery_failure( + ap: Any, + query: pipeline_query.Query, + response_index: int, + message_chain: platform_message.MessageChain, + error: Exception, +) -> None: + try: + plugin_refs = _get_response_sources(query, response_index) + if not plugin_refs: + return + connector = getattr(ap, 'plugin_connector', None) + if connector is None or not hasattr(connector, 'notify_plugin_diagnostic'): + return + for source in plugin_refs: + payload = _build_delivery_failure_payload( + plugin_ref=source.plugin, + event_name=source.event_name, + is_approximate=source.is_approximate, + query=query, + response_index=response_index, + message_chain=message_chain, + error=error, + ) + try: + await connector.notify_plugin_diagnostic(payload) + except Exception as diag_error: + _debug(ap, f'Plugin diagnostic forwarding failed: {diag_error}') + except Exception as diag_error: + _debug(ap, f'Plugin diagnostic forwarding skipped: {diag_error}') + + +def get_emitted_plugins(event_ctx: Any) -> list[dict[str, Any]]: + emitted_plugins = getattr(event_ctx, '_emitted_plugins', []) + return emitted_plugins if isinstance(emitted_plugins, list) else [] + + +def get_response_sources(event_ctx: Any) -> list[dict[str, Any]] | None: + event_attrs = vars(event_ctx) + if '_response_sources' not in event_attrs: + return None + response_sources = event_attrs['_response_sources'] + return response_sources if isinstance(response_sources, list) else [] + + +def _get_or_create_query_state(query: pipeline_query.Query) -> QueryDiagnosticState: + query_key = id(query) + state = _QUERY_STATES.get(query_key) + if state is not None: + return state + + state = QueryDiagnosticState() + try: + state.finalizer = weakref.finalize(query, _discard_query_state, query_key) + except TypeError: + state.finalizer = None + _QUERY_STATES[query_key] = state + return state + + +def _get_query_state(query: pipeline_query.Query) -> QueryDiagnosticState | None: + return _QUERY_STATES.get(id(query)) + + +def _discard_query_state(query_key: int) -> None: + _QUERY_STATES.pop(query_key, None) + + +def _discard_query_state_if_empty(query: pipeline_query.Query) -> None: + query_key = id(query) + state = _QUERY_STATES.get(query_key) + if state is None: + return + if state.pending_by_chain_id or state.by_response_index: + return + if state.finalizer is not None: + state.finalizer.detach() + _discard_query_state(query_key) + + +def _get_response_sources( + query: pipeline_query.Query, + response_index: int, +) -> list[PluginResponseSource]: + state = _get_query_state(query) + if state is None: + return [] + return state.by_response_index.get(response_index, []) + + +def _extract_plugin_ref(plugin: Any) -> dict[str, str] | None: + manifest = plugin.get('manifest') if isinstance(plugin, dict) else None + metadata = manifest.get('metadata') if isinstance(manifest, dict) else None + if not isinstance(metadata, dict): + return None + author = metadata.get('author') + name = metadata.get('name') + if not author or not name: + return None + return {'author': str(author), 'name': str(name)} + + +def _extract_response_source_plugin_ref(source: Any) -> dict[str, str] | None: + if not isinstance(source, dict): + return None + if source.get('kind') != 'reply_message_chain': + return None + plugin_ref = source.get('plugin') + if not isinstance(plugin_ref, dict): + return None + author = plugin_ref.get('author') + name = plugin_ref.get('name') + if not author or not name: + return None + return {'author': str(author), 'name': str(name)} + + +def _build_plugin_sources( + response_sources: list[dict[str, Any]] | None, + emitted_plugins: list[dict[str, Any]] | None, + event_name: str | None, +) -> list[PluginResponseSource]: + if response_sources is not None: + plugin_refs = [_extract_response_source_plugin_ref(source) for source in response_sources] + return [ + PluginResponseSource(plugin=plugin, event_name=event_name) for plugin in plugin_refs if plugin is not None + ] + + if emitted_plugins: + plugin_refs = [_extract_plugin_ref(plugin) for plugin in emitted_plugins] + return [ + PluginResponseSource(plugin=plugin, event_name=event_name, is_approximate=True) + for plugin in plugin_refs + if plugin is not None + ] + return [] + + +def _debug(ap: Any, message: str) -> None: + logger = getattr(ap, 'logger', None) + if logger is not None: + logger.debug(message) + + +def _build_delivery_failure_payload( + plugin_ref: dict[str, str], + event_name: str | None, + is_approximate: bool, + query: pipeline_query.Query, + response_index: int, + message_chain: platform_message.MessageChain, + error: Exception, +) -> dict[str, Any]: + details: dict[str, Any] = { + 'message_component_types': [component.__class__.__name__ for component in message_chain], + 'message_preview': str(message_chain)[:200], + } + if is_approximate: + details['attribution_warning'] = ( + 'This diagnostic was delivered to all plugins that handled the event because the ' + 'plugin runtime did not report the exact reply_message_chain source.' + ) + + return { + 'level': 'ERROR', + 'code': 'response_delivery_failed', + 'message': 'Failed to deliver a plugin-provided response message.', + 'plugin': plugin_ref, + 'query': { + 'query_id': query.query_id, + 'event_name': event_name or query.message_event.__class__.__name__, + 'stage': query.current_stage_name or 'SendResponseBackStage', + 'response_index': response_index, + }, + 'details': details, + 'delivery': { + 'error_type': error.__class__.__name__, + 'error_message': str(error), + 'traceback': traceback.format_exception_only(type(error), error)[-1].strip(), + }, + } diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py index 83ddce893..b14d0a827 100644 --- a/src/langbot/pkg/pipeline/preproc/preproc.py +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -25,6 +25,21 @@ class PreProcessor(stage.PipelineStage): - use_funcs """ + @staticmethod + def _filter_selected_tools( + tools: list, + local_agent_config: dict, + ) -> list: + if local_agent_config.get('enable-all-tools', True) is not False: + return tools + + selected_tools = local_agent_config.get('tools', []) + if not isinstance(selected_tools, list): + return [] + + selected_tool_names = {tool for tool in selected_tools if isinstance(tool, str)} + return [tool for tool in tools if tool.name in selected_tool_names] + async def process( self, query: pipeline_query.Query, @@ -32,6 +47,10 @@ class PreProcessor(stage.PipelineStage): ) -> entities.StageProcessResult: """Process""" selected_runner = query.pipeline_config['ai']['runner']['runner'] + local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {}) + include_skill_authoring = ( + selected_runner == 'local-agent' and getattr(self.ap, 'skill_service', None) is not None + ) session = await self.ap.sess_mgr.get_session(query) @@ -40,7 +59,7 @@ class PreProcessor(stage.PipelineStage): if selected_runner == 'local-agent': # Read model config — new format is { primary: str, fallbacks: [str] }, # but handle legacy plain string for backward compatibility - model_config = query.pipeline_config['ai']['local-agent'].get('model', {}) + model_config = local_agent_config.get('model', {}) if isinstance(model_config, str): # Legacy format: plain UUID string primary_uuid = model_config @@ -106,11 +125,18 @@ class PreProcessor(stage.PipelineStage): if llm_model: query.use_llm_model_uuid = llm_model.model_entity.uuid - if llm_model.model_entity.abilities.__contains__('func_call'): + if 'func_call' in (llm_model.model_entity.abilities or []): # Get bound plugins and MCP servers for filtering tools bound_plugins = query.variables.get('_pipeline_bound_plugins', None) bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) - query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers) + include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True) + all_tools = await self.ap.tool_mgr.get_all_tools( + bound_plugins, + bound_mcp_servers, + include_skill_authoring=include_skill_authoring, + include_mcp_resource_tools=include_mcp_resource_tools, + ) + query.use_funcs = self._filter_selected_tools(all_tools, local_agent_config) self.ap.logger.debug(f'Bound plugins: {bound_plugins}') self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}') @@ -121,7 +147,14 @@ class PreProcessor(stage.PipelineStage): if not query.use_funcs and query.variables.get('_fallback_model_uuids'): bound_plugins = query.variables.get('_pipeline_bound_plugins', None) bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) - query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers) + include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True) + all_tools = await self.ap.tool_mgr.get_all_tools( + bound_plugins, + bound_mcp_servers, + include_skill_authoring=include_skill_authoring, + include_mcp_resource_tools=include_mcp_resource_tools, + ) + query.use_funcs = self._filter_selected_tools(all_tools, local_agent_config) sender_name = '' @@ -148,11 +181,7 @@ class PreProcessor(stage.PipelineStage): # Check if this model supports vision, if not, remove all images # TODO this checking should be performed in runner, and in this stage, the image should be reserved - if ( - selected_runner == 'local-agent' - and llm_model - and not llm_model.model_entity.abilities.__contains__('vision') - ): + if selected_runner == 'local-agent' and llm_model and 'vision' not in (llm_model.model_entity.abilities or []): for msg in query.messages: if isinstance(msg.content, list): for me in msg.content: @@ -170,7 +199,7 @@ class PreProcessor(stage.PipelineStage): plain_text += me.text elif isinstance(me, platform_message.Image): if selected_runner != 'local-agent' or ( - llm_model and llm_model.model_entity.abilities.__contains__('vision') + llm_model and 'vision' in (llm_model.model_entity.abilities or []) ): if me.base64 is not None: content_list.append(provider_message.ContentElement.from_image_base64(me.base64)) @@ -191,7 +220,7 @@ class PreProcessor(stage.PipelineStage): content_list.append(provider_message.ContentElement.from_text(msg.text)) elif isinstance(msg, platform_message.Image): if selected_runner != 'local-agent' or ( - llm_model and llm_model.model_entity.abilities.__contains__('vision') + llm_model and 'vision' in (llm_model.model_entity.abilities or []) ): if msg.base64 is not None: content_list.append(provider_message.ContentElement.from_image_base64(msg.base64)) @@ -237,4 +266,67 @@ class PreProcessor(stage.PipelineStage): query.prompt.messages = event_ctx.event.default_prompt query.messages = event_ctx.event.prompt + # =========== Skill awareness for the local-agent runner =========== + # The actual activation goes through the ``activate`` Tool Call so the + # LLM doesn't see full SKILL.md instructions until it commits to a + # skill (Claude Code's progressive disclosure). But the LLM still has + # to KNOW which skills exist to make that choice, so we: + # 1. resolve the pipeline's bound skills and stash them in + # ``query.variables['_pipeline_bound_skills']`` for downstream + # visibility checks (skill loader, native exec workdir); + # 2. inject a short ``Available Skills`` index (name + description + # only) into the system prompt. The contributor's original PR + # relied on this injection; without it the LLM never discovers + # the skills are there and just calls native tools instead. + if selected_runner == 'local-agent' and self.ap.skill_mgr: + pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid) + extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {}) + enable_all_skills = extensions_prefs.get('enable_all_skills', True) + + if enable_all_skills: + bound_skills = None # None = all loaded skills are visible + else: + bound_skills = extensions_prefs.get('skills', []) + + query.variables['_pipeline_bound_skills'] = bound_skills + + skill_addition = self.ap.skill_mgr.build_skill_aware_prompt_addition( + bound_skills=bound_skills, + ) + if skill_addition: + # Append to the first system message; create one if the + # prompt has none. Handles both plain-string and + # content-element (list) message bodies. + if query.prompt.messages and query.prompt.messages[0].role == 'system': + head = query.prompt.messages[0] + if isinstance(head.content, str): + head.content = head.content + skill_addition + elif isinstance(head.content, list): + appended = False + for ce in head.content: + if getattr(ce, 'type', None) == 'text': + ce.text = (ce.text or '') + skill_addition + appended = True + break + if not appended: + head.content.append(provider_message.ContentElement(type='text', text=skill_addition)) + else: + query.prompt.messages.insert( + 0, + provider_message.Message(role='system', content=skill_addition.strip()), + ) + self.ap.logger.debug( + f'Skill index injected into system prompt: ' + f'pipeline={query.pipeline_uuid} ' + f'bound_skills={bound_skills or "all"} ' + f'loaded_skills={len(self.ap.skill_mgr.skills)}' + ) + else: + self.ap.logger.debug( + f'No skills available for prompt injection: ' + f'pipeline={query.pipeline_uuid} ' + f'loaded_skills={len(self.ap.skill_mgr.skills)} ' + f'bound_skills={bound_skills}' + ) + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) diff --git a/src/langbot/pkg/pipeline/process/handler.py b/src/langbot/pkg/pipeline/process/handler.py index b70a8e043..989cb0b01 100644 --- a/src/langbot/pkg/pipeline/process/handler.py +++ b/src/langbot/pkg/pipeline/process/handler.py @@ -5,6 +5,7 @@ import abc from ...core import app from .. import entities import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.entities.builtin.provider.message as provider_message class MessageHandler(metaclass=abc.ABCMeta): @@ -31,3 +32,29 @@ class MessageHandler(metaclass=abc.ABCMeta): if len(s0) > 20 or '\n' in s: s0 = s0[:20] + '...' return s0 + + def format_result_log( + self, + result: provider_message.Message | provider_message.MessageChunk, + ) -> str | None: + if result.tool_calls: + tool_names = [tc.function.name for tc in result.tool_calls if tc.function and tc.function.name] + if tool_names: + return f'{result.role}: requested tools: {", ".join(tool_names)}' + return f'{result.role}: requested tool calls' + + content = result.content + if isinstance(content, str): + if not content.strip(): + return None + + if result.role == 'tool': + if content.startswith('err:'): + return f'tool error: {self.cut_str(content)}' + + return self.cut_str(result.readable_str()) + + if isinstance(content, list) and len(content) == 0: + return None + + return self.cut_str(result.readable_str()) diff --git a/src/langbot/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py index 87f8d8ce4..4e5c14ea4 100644 --- a/src/langbot/pkg/pipeline/process/handlers/chat.py +++ b/src/langbot/pkg/pipeline/process/handlers/chat.py @@ -9,10 +9,12 @@ from datetime import datetime from .. import handler from ... import entities +from ... import plugin_diagnostics from ....provider import runner as runner_module import langbot_plugin.api.entities.events as events from ....utils import importutil, constants, runner as runner_utils +from ....telemetry import features as telemetry_features from ....provider import runners import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @@ -57,6 +59,13 @@ class ChatMessageHandler(handler.MessageHandler): if event_ctx.is_prevented_default(): if event_ctx.event.reply_message_chain is not None: mc = event_ctx.event.reply_message_chain + plugin_diagnostics.record_pending_plugin_response_source( + query, + mc, + plugin_diagnostics.get_response_sources(event_ctx), + plugin_diagnostics.get_emitted_plugins(event_ctx), + event.event_name, + ) query.resp_messages.append(mc) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) @@ -113,9 +122,11 @@ class ChatMessageHandler(handler.MessageHandler): # This prevents memory overflow from thousands of log entries per conversation # First chunk uses INFO level to confirm connection establishment if chunk_count == 1: - self.ap.logger.info( - f'Conversation({query.query_id}) Streaming started: {self.cut_str(result.readable_str())}' - ) + summary = self.format_result_log(result) + if summary is not None: + self.ap.logger.info(f'Conversation({query.query_id}) Streaming started: {summary}') + else: + self.ap.logger.info(f'Conversation({query.query_id}) Streaming started') elif chunk_count % 10 == 0: self.ap.logger.debug( f'Conversation({query.query_id}) Streaming chunk {chunk_count}: {self.cut_str(result.readable_str())}' @@ -135,9 +146,9 @@ class ChatMessageHandler(handler.MessageHandler): async for result in runner.run(query): query.resp_messages.append(result) - self.ap.logger.info( - f'Conversation({query.query_id}) Response: {self.cut_str(result.readable_str())}' - ) + summary = self.format_result_log(result) + if summary is not None: + self.ap.logger.info(f'Conversation({query.query_id}) Response: {summary}') if result.content is not None: text_length += len(result.content) @@ -199,7 +210,12 @@ class ChatMessageHandler(handler.MessageHandler): runner_name, runner, query.pipeline_config ) + # Feature usage collected during query processing (tool calls, + # knowledge base usage, sandbox executions, activated skills, ...) + features = telemetry_features.collect_features(query) + payload = { + 'event_type': 'query', 'query_id': query.query_id, 'adapter': adapter_name, 'runner': runner_name, @@ -210,6 +226,7 @@ class ChatMessageHandler(handler.MessageHandler): 'instance_id': constants.instance_id, 'edition': constants.edition, 'pipeline_plugins': pipeline_plugins, + 'features': features, 'error': locals().get('error_info', None), 'timestamp': datetime.utcnow().isoformat(), } @@ -217,10 +234,12 @@ class ChatMessageHandler(handler.MessageHandler): # Send telemetry asynchronously and do not block pipeline via app's telemetry manager await self.ap.telemetry.start_send_task(payload) - # Trigger survey event on first successful non-WebSocket response + # Trigger survey events on successful non-WebSocket responses if not locals().get('error_info') and adapter_name and 'WebSocket' not in adapter_name: if self.ap.survey: await self.ap.survey.trigger_event('first_bot_response_success') + # Counts toward the bot_response_success_100 milestone event + await self.ap.survey.record_bot_response_success() except Exception as ex: # Ensure telemetry issues do not affect normal flow self.ap.logger.warning(f'Failed to send telemetry: {ex}') diff --git a/src/langbot/pkg/pipeline/process/handlers/command.py b/src/langbot/pkg/pipeline/process/handlers/command.py index 6d686acd4..09fa5379b 100644 --- a/src/langbot/pkg/pipeline/process/handlers/command.py +++ b/src/langbot/pkg/pipeline/process/handlers/command.py @@ -4,6 +4,7 @@ import typing from .. import handler from ... import entities +from ... import plugin_diagnostics import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @@ -52,6 +53,13 @@ class CommandHandler(handler.MessageHandler): if event_ctx.is_prevented_default(): if event_ctx.event.reply_message_chain is not None: mc = event_ctx.event.reply_message_chain + plugin_diagnostics.record_pending_plugin_response_source( + query, + mc, + plugin_diagnostics.get_response_sources(event_ctx), + plugin_diagnostics.get_emitted_plugins(event_ctx), + event.event_name, + ) query.resp_messages.append(mc) diff --git a/src/langbot/pkg/pipeline/respback/respback.py b/src/langbot/pkg/pipeline/respback/respback.py index 39cba04af..6d8248aef 100644 --- a/src/langbot/pkg/pipeline/respback/respback.py +++ b/src/langbot/pkg/pipeline/respback/respback.py @@ -9,6 +9,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.provider.message as provider_message from .. import stage, entities +from .. import plugin_diagnostics import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @@ -39,20 +40,35 @@ class SendResponseBackStage(stage.PipelineStage): has_chunks = any(isinstance(msg, provider_message.MessageChunk) for msg in query.resp_messages) # TODO 命令与流式的兼容性问题 - if await query.adapter.is_stream_output_supported() and has_chunks: - is_final = [msg.is_final for msg in query.resp_messages][-1] - await query.adapter.reply_message_chunk( - message_source=query.message_event, - bot_message=query.resp_messages[-1], - message=query.resp_message_chain[-1], - quote_origin=quote_origin, - is_final=is_final, - ) - else: - await query.adapter.reply_message( - message_source=query.message_event, - message=query.resp_message_chain[-1], - quote_origin=quote_origin, + response_index = len(query.resp_message_chain) - 1 + message_chain = query.resp_message_chain[-1] + + try: + if await query.adapter.is_stream_output_supported() and has_chunks: + is_final = [msg.is_final for msg in query.resp_messages][-1] + await query.adapter.reply_message_chunk( + message_source=query.message_event, + bot_message=query.resp_messages[-1], + message=message_chain, + quote_origin=quote_origin, + is_final=is_final, + ) + else: + await query.adapter.reply_message( + message_source=query.message_event, + message=message_chain, + quote_origin=quote_origin, + ) + except Exception as e: + await plugin_diagnostics.notify_response_delivery_failure( + self.ap, + query, + response_index, + message_chain, + e, ) + plugin_diagnostics.clear_response_source(query, response_index) + raise + plugin_diagnostics.clear_response_source(query, response_index) return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) diff --git a/src/langbot/pkg/pipeline/wrapper/wrapper.py b/src/langbot/pkg/pipeline/wrapper/wrapper.py index a1ebc97a2..50db693d4 100644 --- a/src/langbot/pkg/pipeline/wrapper/wrapper.py +++ b/src/langbot/pkg/pipeline/wrapper/wrapper.py @@ -3,10 +3,12 @@ from __future__ import annotations import typing from .. import entities +from .. import plugin_diagnostics from .. import stage import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.events as events @@ -23,6 +25,50 @@ class ResponseWrapper(stage.PipelineStage): async def initialize(self, pipeline_config: dict): pass + def _is_final_assistant_message(self, result) -> bool: + """Whether *result* is the agent's final, tool-call-free answer. + + Intermediate streaming chunks and tool-call rounds must NOT trigger + outbound attachment collection — only the terminal assistant message. + """ + if getattr(result, 'role', None) != 'assistant': + return False + if result.tool_calls: + return False + if isinstance(result, provider_message.MessageChunk): + return bool(result.is_final) + return True + + async def _append_outbound_attachments( + self, + query: pipeline_query.Query, + message_chain: platform_message.MessageChain, + ) -> None: + """Collect sandbox outbox files and append them to *message_chain*. + + Runs at most once per query (guarded by a query variable) and never + raises into the pipeline — attachment delivery is best-effort. + """ + if query.variables.get('_sandbox_outbound_collected'): + return + box_service = getattr(self.ap, 'box_service', None) + if box_service is None or not getattr(box_service, 'available', False): + return + query.variables['_sandbox_outbound_collected'] = True + try: + attachments = await box_service.collect_outbound_attachments(query) + except Exception as e: + self.ap.logger.warning(f'Outbound attachment collection failed: {e}') + return + for att in attachments: + att_type = att.get('type') + if att_type == 'Image': + message_chain.append(platform_message.Image(base64=att['base64'])) + elif att_type == 'Voice': + message_chain.append(platform_message.Voice(base64=att['base64'])) + else: + message_chain.append(platform_message.File(name=att.get('name', 'file'), base64=att['base64'])) + async def process( self, query: pipeline_query.Query, @@ -33,6 +79,11 @@ class ResponseWrapper(stage.PipelineStage): # 如果 resp_messages[-1] 已经是 MessageChain 了 if isinstance(query.resp_messages[-1], platform_message.MessageChain): query.resp_message_chain.append(query.resp_messages[-1]) + plugin_diagnostics.consume_pending_plugin_response_source( + query, + query.resp_messages[-1], + len(query.resp_message_chain) - 1, + ) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) @@ -83,10 +134,25 @@ class ResponseWrapper(stage.PipelineStage): ) else: if event_ctx.event.reply_message_chain is not None: - query.resp_message_chain.append(event_ctx.event.reply_message_chain) - + reply_chain = event_ctx.event.reply_message_chain + is_plugin_reply = True else: - query.resp_message_chain.append(result.get_content_platform_message_chain()) + reply_chain = result.get_content_platform_message_chain() + is_plugin_reply = False + + # Attach files the agent produced in the sandbox + # outbox, but only on the terminal assistant message. + if self._is_final_assistant_message(result): + await self._append_outbound_attachments(query, reply_chain) + + query.resp_message_chain.append(reply_chain) + if is_plugin_reply: + plugin_diagnostics.record_last_plugin_response_source( + query, + plugin_diagnostics.get_response_sources(event_ctx), + plugin_diagnostics.get_emitted_plugins(event_ctx), + event.event_name, + ) yield entities.StageProcessResult( result_type=entities.ResultType.CONTINUE, @@ -129,6 +195,12 @@ class ResponseWrapper(stage.PipelineStage): else: if event_ctx.event.reply_message_chain is not None: query.resp_message_chain.append(event_ctx.event.reply_message_chain) + plugin_diagnostics.record_last_plugin_response_source( + query, + plugin_diagnostics.get_response_sources(event_ctx), + plugin_diagnostics.get_emitted_plugins(event_ctx), + event.event_name, + ) else: query.resp_message_chain.append( diff --git a/src/langbot/pkg/platform/sources/aiocqhttp.py b/src/langbot/pkg/platform/sources/aiocqhttp.py index 3cb55d89d..abe1ca95b 100644 --- a/src/langbot/pkg/platform/sources/aiocqhttp.py +++ b/src/langbot/pkg/platform/sources/aiocqhttp.py @@ -4,6 +4,7 @@ import asyncio import traceback import datetime import json +import time import aiocqhttp import pydantic @@ -16,6 +17,37 @@ from ...utils import image import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +_GROUP_NAME_CACHE_TTL_SECONDS = 3600 +_GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS = 60 +_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS = 2 +_GROUP_MEMBER_INFO_CACHE_TTL_SECONDS = 86400 +_GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS = 600 +_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS = 2 + + +def _normalize_base64_payload(value: str) -> str: + if value.startswith('base64://'): + return value.removeprefix('base64://') + if value.startswith('data:') and ';base64,' in value: + return value.split(';base64,', 1)[1] + return value + + +def _get_field(data: dict, key: str, default: str = '') -> str: + value = data.get(key) + if value is None: + return default + return str(value) + + +def _get_group_member_name(sender: dict) -> str: + return _get_field(sender, 'card') or _get_field(sender, 'nickname') or _get_field(sender, 'user_id') + + +def _get_group_name_placeholder(group_id: typing.Union[int, str]) -> str: + return f'Group {group_id}' + + class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod async def yiri2target( @@ -35,7 +67,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert elif type(msg) is platform_message.Image: arg = '' if msg.base64: - arg = msg.base64 + arg = _normalize_base64_payload(msg.base64) msg_list.append(aiocqhttp.MessageSegment.image(f'base64://{arg}')) elif msg.url: arg = msg.url @@ -50,7 +82,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert elif type(msg) is platform_message.Voice: arg = '' if msg.base64: - arg = msg.base64 + arg = _normalize_base64_payload(msg.base64) msg_list.append(aiocqhttp.MessageSegment.record(f'base64://{arg}')) elif msg.url: arg = msg.url @@ -62,7 +94,10 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert for node in msg.node_list: msg_list.extend((await AiocqhttpMessageConverter.yiri2target(node.message_chain))[0]) elif isinstance(msg, platform_message.File): - msg_list.append({'type': 'file', 'data': {'file': msg.url, 'name': msg.name}}) + file = msg.url or msg.path + if not file and msg.base64: + file = f'base64://{_normalize_base64_payload(msg.base64)}' + msg_list.append({'type': 'file', 'data': {'file': file, 'name': msg.name}}) elif isinstance(msg, platform_message.Face): if msg.face_type == 'face': msg_list.append(aiocqhttp.MessageSegment.face(msg.face_id)) @@ -324,16 +359,96 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): + def __init__(self): + self._group_name_cache: dict[typing.Union[int, str], tuple[str, float]] = {} + self._group_name_negative_cache: dict[typing.Union[int, str], float] = {} + self._group_member_info_cache: dict[ + tuple[typing.Union[int, str], typing.Union[int, str]], tuple[dict, float] + ] = {} + self._group_member_info_negative_cache: dict[tuple[typing.Union[int, str], typing.Union[int, str]], float] = {} + @staticmethod async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int): return event.source_platform_object - @staticmethod - async def target2yiri(event: aiocqhttp.Event, bot=None): + async def _get_group_name(self, group_id: typing.Union[int, str], bot=None) -> str: + now = time.monotonic() + if group_id in self._group_name_cache: + group_name, expires_at = self._group_name_cache[group_id] + if expires_at > now: + return group_name + del self._group_name_cache[group_id] + if group_id in self._group_name_negative_cache: + expires_at = self._group_name_negative_cache[group_id] + if expires_at > now: + return '' + del self._group_name_negative_cache[group_id] + if bot is None: + return '' + try: + group_info = await asyncio.wait_for( + bot.get_group_info(group_id=group_id), + timeout=_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS, + ) + except Exception: + self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS + return '' + group_name = _get_field(group_info, 'group_name') if isinstance(group_info, dict) else '' + if group_name: + self._group_name_cache[group_id] = (group_name, now + _GROUP_NAME_CACHE_TTL_SECONDS) + self._group_name_negative_cache.pop(group_id, None) + else: + self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS + return group_name + + async def _get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + bot=None, + ) -> dict: + now = time.monotonic() + cache_key = (group_id, user_id) + if cache_key in self._group_member_info_cache: + member_info, expires_at = self._group_member_info_cache[cache_key] + if expires_at > now: + return member_info + del self._group_member_info_cache[cache_key] + if cache_key in self._group_member_info_negative_cache: + expires_at = self._group_member_info_negative_cache[cache_key] + if expires_at > now: + return {} + del self._group_member_info_negative_cache[cache_key] + if bot is None: + return {} + try: + member_info = await asyncio.wait_for( + bot.get_group_member_info(group_id=group_id, user_id=user_id), + timeout=_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS, + ) + except Exception: + self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS + return {} + if isinstance(member_info, dict) and member_info: + self._group_member_info_cache[cache_key] = ( + member_info, + now + _GROUP_MEMBER_INFO_CACHE_TTL_SECONDS, + ) + self._group_member_info_negative_cache.pop(cache_key, None) + return member_info + self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS + return {} + + async def target2yiri(self, event: aiocqhttp.Event, bot=None): yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot) if event.message_type == 'group': permission = 'MEMBER' + group_name = await self._get_group_name(event.group_id, bot) or _get_group_name_placeholder(event.group_id) + special_title = _get_field(event.sender, 'title') + if not special_title: + member_info = await self._get_group_member_info(event.group_id, event.sender['user_id'], bot) + special_title = _get_field(member_info, 'title') if 'role' in event.sender: if event.sender['role'] == 'admin': @@ -343,14 +458,14 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): converted_event = platform_events.GroupMessage( sender=platform_entities.GroupMember( id=event.sender['user_id'], # message_seq 放哪? - member_name=event.sender['nickname'], + member_name=_get_group_member_name(event.sender), permission=permission, group=platform_entities.Group( id=event.group_id, - name=event.sender['nickname'], + name=group_name, permission=platform_entities.Permission.Member, ), - special_title=event.sender['title'] if 'title' in event.sender else '', + special_title=special_title, ), message_chain=yiri_chain, time=event.time, @@ -374,7 +489,7 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True, default_factory=aiocqhttp.CQHttp) message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter() - event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter() + event_converter: AiocqhttpEventConverter = pydantic.Field(default_factory=AiocqhttpEventConverter) on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = [] @@ -433,9 +548,7 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) elif isinstance(component, platform_message.Image): img_data = {} if component.base64: - b64 = component.base64 - if b64.startswith('data:'): - b64 = b64.split(',', 1)[-1] if ',' in b64 else b64 + b64 = _normalize_base64_payload(component.base64) img_data['file'] = f'base64://{b64}' elif component.url: img_data['file'] = component.url diff --git a/src/langbot/pkg/platform/sources/http_bot.py b/src/langbot/pkg/platform/sources/http_bot.py new file mode 100644 index 000000000..16a891991 --- /dev/null +++ b/src/langbot/pkg/platform/sources/http_bot.py @@ -0,0 +1,509 @@ +"""HTTP Bot adapter — standalone server-to-server platform adapter. + +Lets any external backend drive a LangBot pipeline over plain HTTP: + +* **Inbound** — the backend POSTs a signed message to the unified webhook + route ``POST /bots/``; this adapter verifies the signature, builds + a platform event carrying the caller-defined ``session_id`` as the launcher + id, and fires it into the normal pipeline (so message aggregation, N->1, + works for free). +* **Outbound** — every ``reply_message`` / ``reply_message_chunk`` the pipeline + emits is delivered as a signed POST to the configured ``callback_url``. A + single turn may emit many replies (1->M); each is one callback, ordered per + session via a small worker queue. + +Design notes: + +* The callback URL is taken **only** from adapter config (never from the + inbound message) to keep the SSRF surface closed. +* Replies for one ``session_id`` are delivered in ``sequence`` order; the + caller knows a turn is complete when ``is_final: true`` arrives. +* No new HTTP route is registered — the existing unified webhook dispatcher + (``pkg/api/http/controller/groups/webhooks.py``) calls + ``handle_unified_webhook`` on this adapter. + +See docs/platforms/http-bot.md for the full integration guide. +""" + +from __future__ import annotations + +import asyncio +import json +import time +import typing +import uuid +from datetime import datetime + +import aiohttp +import pydantic +import quart + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger + +from . import http_bot_signing as signing +from ...utils import httpclient + + +# Error envelope codes (HTTP status -> body code), documented in the design doc. +_ERR = { + 'bad_request': (400, 40001), + 'bad_signature': (401, 40101), + 'duplicate': (409, 40901), + 'too_large': (413, 41301), + 'internal': (500, 50001), +} + +# Max accepted inbound body size (bytes). +_MAX_BODY = 1 * 1024 * 1024 + +# Idempotency dedup window (seconds) and cap. +_IDEMPOTENCY_TTL = 600 +_IDEMPOTENCY_MAX = 4096 + + +class _SessionOutbound: + """Per-session outbound state: ordered delivery queue + sequence counter.""" + + def __init__(self) -> None: + self.queue: asyncio.Queue = asyncio.Queue(maxsize=1000) + self.worker: asyncio.Task | None = None + self.sequence: int = 0 + self.last_was_final: bool = True # so the first reply of a turn starts at seq 1 + + +class _SyncCollector: + """Collects reply parts for a /sync request and resolves when the turn ends.""" + + def __init__(self) -> None: + self.parts: list = [] + self.done: asyncio.Event = asyncio.Event() + + +class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + """Standalone HTTP adapter (inbound webhook + outbound callbacks).""" + + bot_uuid: str = pydantic.Field(default='', exclude=True) + + listeners: dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = pydantic.Field(default_factory=dict, exclude=True) + + # session_id -> outbound state + outbound_states: dict[str, _SessionOutbound] = pydantic.Field(default_factory=dict, exclude=True) + # idempotency key -> accepted-at epoch + idempotency_cache: dict[str, float] = pydantic.Field(default_factory=dict, exclude=True) + # session_id -> sync collector (set while a /sync request is awaiting a turn) + sync_waiters: dict[str, '_SyncCollector'] = pydantic.Field(default_factory=dict, exclude=True) + + model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): + super().__init__(config=config, logger=logger, **kwargs) + self.bot_account_id = 'http_bot' + self.outbound_states = {} + self.idempotency_cache = {} + self.sync_waiters = {} + + # -- framework hooks ------------------------------------------------------ + + def set_bot_uuid(self, bot_uuid: str) -> None: + """Called by the bot manager so the adapter knows its own bot uuid.""" + object.__setattr__(self, 'bot_uuid', bot_uuid) + + def get_launcher_id(self, event: platform_events.MessageEvent) -> str: + """Map an inbound event to a LangBot launcher id. + + We return the caller-defined ``session_id`` (stashed on the sender / + group id at inbound time) so that each external session maps 1:1 to an + isolated LangBot session. + """ + if isinstance(event, platform_events.GroupMessage): + return str(event.sender.group.id) + return str(event.sender.id) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + func: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None] + ], + ): + self.listeners[event_type] = func + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + func: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None] + ], + ): + self.listeners.pop(event_type, None) + + async def is_muted(self, group_id: int) -> bool: + return False + + async def is_stream_output_supported(self) -> bool: + return True + + async def run_async(self): + # Purely webhook-driven; nothing to poll. Stay alive. + while True: + await asyncio.sleep(3600) + + async def kill(self): + # Cancel any outbound workers. + for state in self.outbound_states.values(): + if state.worker and not state.worker.done(): + state.worker.cancel() + return True + + # -- inbound -------------------------------------------------------------- + + def _err(self, kind: str, detail: str = ''): + status, code = _ERR[kind] + return quart.jsonify({'code': code, 'msg': detail or kind, 'data': None}), status + + def _prune_idempotency(self) -> None: + now = time.time() + if len(self.idempotency_cache) > _IDEMPOTENCY_MAX: + self.idempotency_cache.clear() + return + expired = [k for k, ts in self.idempotency_cache.items() if now - ts > _IDEMPOTENCY_TTL] + for k in expired: + self.idempotency_cache.pop(k, None) + + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + """Handle an inbound POST from the unified webhook dispatcher. + + Sub-path routing: + (no path) -> push a message + "reset" -> reset a session's conversation (body: {session_id, session_type?}) + "sync" -> push a message and wait for the final reply (collapses 1->M) + """ + object.__setattr__(self, 'bot_uuid', bot_uuid) + + if path == 'reset': + return await self._handle_reset(request) + if path == 'sync': + return await self._handle_inbound(request, sync=True) + if path in ('', None): + return await self._handle_inbound(request, sync=False) + return self._err('bad_request', f'unknown sub-path: {path}') + + async def _read_and_verify(self, request) -> tuple[dict | None, typing.Any]: + """Read body, enforce size + signature. Returns (data, error_response).""" + body = await request.get_data() + if body and len(body) > _MAX_BODY: + return None, self._err('too_large', 'message too large') + + if self.config.get('signature_required', True): + ok, reason = signing.verify( + secret=self.config.get('inbound_secret', ''), + body=body, + timestamp=request.headers.get(signing.HEADER_TIMESTAMP), + signature=request.headers.get(signing.HEADER_SIGNATURE), + ) + if not ok: + await self.logger.warning(f'http_bot inbound signature rejected: {reason}') + return None, self._err('bad_signature', f'invalid signature: {reason}') + + try: + data = json.loads(body) + except (json.JSONDecodeError, ValueError): + return None, self._err('bad_request', 'body is not valid JSON') + if not isinstance(data, dict): + return None, self._err('bad_request', 'body must be a JSON object') + return data, None + + def _build_event(self, data: dict) -> tuple[platform_events.MessageEvent, str, str, str]: + """Build a platform event from inbound data. + + Returns (event, session_id, session_type, message_id). + """ + session_id = str(data['session_id']) + session_type = data.get('session_type') or self.config.get('default_session_type', 'person') + sender_meta = data.get('sender') or {} + sender_name = str(sender_meta.get('name', 'User')) + + message_id = 'in_' + uuid.uuid4().hex + chain = platform_message.MessageChain.model_validate(data['message']) + # Carry the inbound message id + timestamp as the Source component. + chain.insert(0, platform_message.Source(id=message_id, time=datetime.now())) + + if session_type == 'group': + group = platform_entities.Group( + id=session_id, + name=str(sender_meta.get('group_name', session_id)), + permission=platform_entities.Permission.Member, + ) + sender = platform_entities.GroupMember( + id=str(sender_meta.get('id', session_id)), + member_name=sender_name, + group=group, + permission=platform_entities.Permission.Member, + ) + event = platform_events.GroupMessage(sender=sender, message_chain=chain, time=datetime.now().timestamp()) + else: + sender = platform_entities.Friend(id=session_id, nickname=sender_name, remark=sender_name) + event = platform_events.FriendMessage(sender=sender, message_chain=chain, time=datetime.now().timestamp()) + return event, session_id, session_type, message_id + + async def _handle_inbound(self, request, sync: bool): + data, err = await self._read_and_verify(request) + if err is not None: + return err + + if 'session_id' not in data or 'message' not in data: + return self._err('bad_request', 'session_id and message are required') + + # Idempotency. + idem = request.headers.get(signing.HEADER_IDEMPOTENCY) + if idem: + self._prune_idempotency() + if idem in self.idempotency_cache: + return self._err('duplicate', 'idempotency key already accepted') + self.idempotency_cache[idem] = time.time() + + try: + event, session_id, session_type, message_id = self._build_event(data) + except Exception as e: # noqa: BLE001 + return self._err('bad_request', f'failed to parse message: {e}') + + listener = self.listeners.get(type(event)) + if listener is None: + return self._err('internal', 'no listener registered for event type') + + if sync: + return await self._run_sync(event, listener, session_id, message_id) + + # Fire-and-collect: kick the pipeline, return 202 immediately. + asyncio.create_task(listener(event, self)) + return quart.jsonify( + { + 'code': 0, + 'msg': 'accepted', + 'data': { + 'session_id': session_id, + 'accepted_message_id': message_id, + 'aggregating': True, + }, + } + ), 202 + + async def _handle_reset(self, request): + data, err = await self._read_and_verify(request) + if err is not None: + return err + if 'session_id' not in data: + return self._err('bad_request', 'session_id is required') + + session_id = str(data['session_id']) + session_type = data.get('session_type') or self.config.get('default_session_type', 'person') + launcher_type = 'group' if session_type == 'group' else 'person' + + removed = await self._reset_session(launcher_type, session_id) + return quart.jsonify({'code': 0, 'msg': 'reset', 'data': {'session_id': session_id, 'removed': removed}}), 200 + + async def _reset_session(self, launcher_type: str, launcher_id: str) -> bool: + """Drop the matching session so the next message starts a fresh conversation.""" + sess_mgr = self.ap.sess_mgr + before = len(sess_mgr.session_list) + sess_mgr.session_list = [ + s + for s in sess_mgr.session_list + if not ( + str(s.launcher_type.value if hasattr(s.launcher_type, 'value') else s.launcher_type) == launcher_type + and str(s.launcher_id) == launcher_id + ) + ] + return len(sess_mgr.session_list) < before + + # -- outbound ------------------------------------------------------------- + + @staticmethod + def _extract_session_id(message_source: platform_events.MessageEvent) -> str: + if isinstance(message_source, platform_events.GroupMessage): + return str(message_source.sender.group.id) + return str(message_source.sender.id) + + @staticmethod + def _extract_reply_to(message_source: platform_events.MessageEvent) -> str: + for comp in message_source.message_chain: + if isinstance(comp, platform_message.Source): + return str(comp.id) + return '' + + def _next_sequence(self, session_id: str, is_final: bool) -> int: + state = self.outbound_states.setdefault(session_id, _SessionOutbound()) + if state.last_was_final: + state.sequence = 1 + else: + state.sequence += 1 + state.last_was_final = is_final + return state.sequence + + async def _enqueue_callback(self, session_id: str, payload: dict) -> None: + state = self.outbound_states.setdefault(session_id, _SessionOutbound()) + if state.worker is None or state.worker.done(): + state.worker = asyncio.create_task(self._outbound_worker(session_id, state)) + try: + state.queue.put_nowait(payload) + except asyncio.QueueFull: + # Drop oldest to bound memory, then enqueue (best-effort, at-least-once). + try: + state.queue.get_nowait() + except asyncio.QueueEmpty: + pass + await self.logger.warning(f'http_bot outbound queue full for session {session_id}; dropped oldest') + state.queue.put_nowait(payload) + + async def _outbound_worker(self, session_id: str, state: _SessionOutbound) -> None: + while True: + payload = await state.queue.get() + try: + await self._deliver_callback(payload) + except Exception as e: # noqa: BLE001 + await self.logger.error(f'http_bot callback delivery failed for {session_id}: {e}') + finally: + state.queue.task_done() + + async def _deliver_callback(self, payload: dict) -> None: + callback_url = self.config.get('callback_url', '') + if not callback_url: + await self.logger.warning('http_bot has no callback_url configured; dropping reply') + return + + body = json.dumps(payload, ensure_ascii=False).encode() + secret = self.config.get('outbound_secret') or self.config.get('inbound_secret', '') + ts, sig = signing.sign(secret, body) + headers = { + 'Content-Type': 'application/json', + signing.HEADER_TIMESTAMP: ts, + signing.HEADER_SIGNATURE: sig, + } + timeout = aiohttp.ClientTimeout(total=int(self.config.get('callback_timeout', 15))) + max_retries = int(self.config.get('callback_max_retries', 3)) + + session = httpclient.get_session() + attempt = 0 + while True: + attempt += 1 + try: + async with session.post(callback_url, data=body, headers=headers, timeout=timeout) as resp: + if resp.status < 400: + return + if resp.status < 500 or attempt > max_retries: + await self.logger.warning(f'http_bot callback {callback_url} -> {resp.status}, giving up') + return + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + if attempt > max_retries: + await self.logger.warning(f'http_bot callback {callback_url} failed after {attempt} tries: {e}') + return + await asyncio.sleep(min(2 ** (attempt - 1), 30)) + + async def _emit_reply( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + is_final: bool, + stream: bool, + ) -> dict: + session_id = self._extract_session_id(message_source) + reply_to = self._extract_reply_to(message_source) + sequence = self._next_sequence(session_id, is_final) + parts = [c.model_dump() if hasattr(c, 'model_dump') else c.__dict__ for c in message] + payload = { + 'session_id': session_id, + 'reply_to': reply_to, + 'sequence': sequence, + 'is_final': is_final, + 'stream': stream, + 'message': parts, + 'timestamp': datetime.now().isoformat(), + } + + # If a /sync request is awaiting this session, collect instead of POSTing. + collector = self.sync_waiters.get(session_id) + if collector is not None: + collector.parts.extend(parts) + if is_final: + collector.done.set() + return payload + + await self._enqueue_callback(session_id, payload) + return payload + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain) -> dict: + """Proactively push a message to a session (target_id == session_id).""" + sequence = self._next_sequence(str(target_id), is_final=True) + payload = { + 'session_id': str(target_id), + 'reply_to': '', + 'sequence': sequence, + 'is_final': True, + 'stream': False, + 'message': [c.model_dump() if hasattr(c, 'model_dump') else c.__dict__ for c in message], + 'timestamp': datetime.now().isoformat(), + } + await self._enqueue_callback(str(target_id), payload) + return payload + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ) -> dict: + return await self._emit_reply(message_source, message, is_final=True, stream=False) + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ) -> dict: + message_is_final = is_final and getattr(bot_message, 'tool_calls', None) is None + return await self._emit_reply(message_source, message, is_final=message_is_final, stream=True) + + # -- sync convenience mode ------------------------------------------------ + + async def _run_sync(self, event, listener, session_id: str, message_id: str): + """Push a message and wait for the final reply, collapsing 1->M parts. + + Lossy by design (drops streaming/ordering nuance); documented as such. + Concurrency-safe: routing is via the per-session ``_sync_waiters`` + registry that ``_emit_reply`` consults, not by patching methods. + """ + if session_id in self.sync_waiters: + return self._err('duplicate', 'a sync request is already in flight for this session') + + collector = _SyncCollector() + self.sync_waiters[session_id] = collector + try: + asyncio.create_task(listener(event, self)) + timeout = int(self.config.get('callback_timeout', 15)) * 4 + try: + await asyncio.wait_for(collector.done.wait(), timeout=timeout) + except asyncio.TimeoutError: + await self.logger.warning(f'http_bot sync wait timed out for session {session_id}') + finally: + self.sync_waiters.pop(session_id, None) + + return quart.jsonify( + { + 'code': 0, + 'msg': 'ok', + 'data': { + 'session_id': session_id, + 'reply_to': message_id, + 'message': collector.parts, + }, + } + ), 200 diff --git a/src/langbot/pkg/platform/sources/http_bot.svg b/src/langbot/pkg/platform/sources/http_bot.svg new file mode 100644 index 000000000..e092e755f --- /dev/null +++ b/src/langbot/pkg/platform/sources/http_bot.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/langbot/pkg/platform/sources/http_bot.yaml b/src/langbot/pkg/platform/sources/http_bot.yaml new file mode 100644 index 000000000..56ef57f26 --- /dev/null +++ b/src/langbot/pkg/platform/sources/http_bot.yaml @@ -0,0 +1,153 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: http_bot + label: + en_US: HTTP Bot + zh_Hans: HTTP 通用接入 + zh_Hant: HTTP 通用接入 + ja_JP: HTTP ボット + description: + en_US: Integrate any backend over plain HTTP. Push messages in via a signed webhook, receive replies on a callback URL. Server-to-server, no long-lived connection. Preserves message aggregation (N->1) and multi-part replies (1->M). + zh_Hans: 通过 HTTP 接入任意后端系统。以签名 Webhook 推入消息,在回调地址接收回复。面向服务间集成,无需长连接。完整保留消息聚合(多条合一)与多段回复(一条问、多条回)能力。 + zh_Hant: 透過 HTTP 接入任意後端系統。以簽名 Webhook 推入訊息,在回調地址接收回覆。面向服務間整合,無需長連線。完整保留訊息聚合(多條合一)與多段回覆(一條問、多條回)能力。 + ja_JP: 任意のバックエンドを HTTP で接続。署名付き Webhook でメッセージを送信し、コールバック URL で返信を受信します。サーバー間連携、長時間接続不要。メッセージ集約(N→1)とマルチパート返信(1→M)に対応。 + icon: http_bot.svg +spec: + categories: + - popular + - global + help_links: + zh: https://docs.langbot.app/zh/platforms/http-bot + en: https://docs.langbot.app/en/platforms/http-bot + ja: https://docs.langbot.app/ja/platforms/http-bot + config: + - name: webhook_url + label: + en_US: Inbound Webhook URL + zh_Hans: 入站 Webhook 地址 + zh_Hant: 入站 Webhook 地址 + ja_JP: 受信 Webhook URL + description: + en_US: Copy this URL. Your backend POSTs messages here (signed with the inbound secret). + zh_Hans: 复制此地址。你的后端将消息以签名方式 POST 到这里。 + zh_Hant: 複製此地址。你的後端將訊息以簽名方式 POST 到這裡。 + ja_JP: この URL をコピーしてください。バックエンドは署名付きでここにメッセージを POST します。 + type: webhook-url + required: false + default: "" + - name: inbound_secret + label: + en_US: Inbound Signing Secret + zh_Hans: 入站签名密钥 + zh_Hant: 入站簽名密鑰 + ja_JP: 受信署名シークレット + description: + en_US: HMAC-SHA256 secret your backend uses to sign inbound requests. LangBot verifies every inbound POST with it. + zh_Hans: 你的后端用于对入站请求做 HMAC-SHA256 签名的密钥;LangBot 据此校验每个入站 POST。 + zh_Hant: 你的後端用於對入站請求做 HMAC-SHA256 簽名的密鑰;LangBot 據此校驗每個入站 POST。 + ja_JP: バックエンドが受信リクエストの署名に使う HMAC-SHA256 シークレット。LangBot は受信 POST ごとに検証します。 + type: string + required: true + default: "" + - name: callback_url + label: + en_US: Outbound Callback URL + zh_Hans: 出站回调地址 + zh_Hant: 出站回調地址 + ja_JP: 送信コールバック URL + description: + en_US: Where LangBot POSTs replies. One turn may trigger multiple callbacks (1->M). For security the callback URL is taken ONLY from this config and cannot be overridden per-message. + zh_Hans: LangBot 将回复 POST 到此地址。一轮对话可能触发多次回调(一问多答)。出于安全考虑,回调地址只取自此配置,不允许逐条消息覆盖。 + zh_Hant: LangBot 將回覆 POST 到此地址。一輪對話可能觸發多次回調(一問多答)。出於安全考慮,回調地址只取自此配置,不允許逐條訊息覆蓋。 + ja_JP: LangBot が返信を POST する先。1 ターンで複数回のコールバック(1→M)が発生し得ます。セキュリティ上、コールバック URL はこの設定からのみ取得し、メッセージ単位で上書きできません。 + type: string + required: true + default: "" + - name: outbound_secret + label: + en_US: Outbound Signing Secret + zh_Hans: 出站签名密钥 + zh_Hant: 出站簽名密鑰 + ja_JP: 送信署名シークレット + description: + en_US: HMAC-SHA256 secret LangBot uses to sign outbound callbacks so your receiver can verify them. Falls back to the inbound secret when empty. + zh_Hans: LangBot 用于对出站回调签名的密钥,供你的接收端校验。留空时回退使用入站密钥。 + zh_Hant: LangBot 用於對出站回調簽名的密鑰,供你的接收端校驗。留空時回退使用入站密鑰。 + ja_JP: LangBot が送信コールバックの署名に使う HMAC-SHA256 シークレット。受信側で検証できます。空の場合は受信シークレットを使用します。 + type: string + required: false + default: "" + - name: default_session_type + label: + en_US: Default Session Type + zh_Hans: 默认会话类型 + zh_Hant: 預設會話類型 + ja_JP: デフォルトセッションタイプ + description: + en_US: Session type used when an inbound message omits session_type. + zh_Hans: 入站消息未携带 session_type 时使用的会话类型。 + zh_Hant: 入站訊息未攜帶 session_type 時使用的會話類型。 + ja_JP: 受信メッセージに session_type がない場合に使用するセッションタイプ。 + type: select + options: + - name: person + label: + en_US: Person (1-on-1) + zh_Hans: 个人(一对一) + zh_Hant: 個人(一對一) + ja_JP: 個人(1 対 1) + - name: group + label: + en_US: Group + zh_Hans: 群组 + zh_Hant: 群組 + ja_JP: グループ + required: false + default: person + - name: signature_required + label: + en_US: Require Inbound Signature + zh_Hans: 强制入站签名校验 + zh_Hant: 強制入站簽名校驗 + ja_JP: 受信署名を必須にする + description: + en_US: When enabled (recommended), every inbound POST must carry a valid signature. Disable ONLY for local development behind a trusted network. + zh_Hans: 开启(推荐)后,每个入站 POST 都必须带有效签名。仅在受信任内网的本地开发时关闭。 + zh_Hant: 開啟(推薦)後,每個入站 POST 都必須帶有效簽名。僅在受信任內網的本地開發時關閉。 + ja_JP: 有効(推奨)にすると、すべての受信 POST に有効な署名が必要です。信頼できるネットワーク内のローカル開発時のみ無効化してください。 + type: boolean + required: false + default: true + - name: callback_timeout + label: + en_US: Callback Timeout (seconds) + zh_Hans: 回调超时(秒) + zh_Hant: 回調逾時(秒) + ja_JP: コールバックタイムアウト(秒) + description: + en_US: Per-callback HTTP timeout. + zh_Hans: 单次回调的 HTTP 超时时间。 + zh_Hant: 單次回調的 HTTP 逾時時間。 + ja_JP: コールバックごとの HTTP タイムアウト。 + type: integer + required: false + default: 15 + - name: callback_max_retries + label: + en_US: Callback Max Retries + zh_Hans: 回调最大重试次数 + zh_Hant: 回調最大重試次數 + ja_JP: コールバック最大リトライ回数 + description: + en_US: Retries on timeout or 5xx, with exponential backoff. + zh_Hans: 超时或 5xx 时按指数退避重试的次数。 + zh_Hant: 逾時或 5xx 時按指數退避重試的次數。 + ja_JP: タイムアウトまたは 5xx 時に指数バックオフでリトライする回数。 + type: integer + required: false + default: 3 +execution: + python: + path: ./http_bot.py + attr: HttpBotAdapter diff --git a/src/langbot/pkg/platform/sources/http_bot_signing.py b/src/langbot/pkg/platform/sources/http_bot_signing.py new file mode 100644 index 000000000..0578721c4 --- /dev/null +++ b/src/langbot/pkg/platform/sources/http_bot_signing.py @@ -0,0 +1,95 @@ +"""HMAC signing utilities for the HTTP Bot adapter. + +A dependency-free, symmetric HMAC-SHA256 scheme used in *both* directions: + + signing_string = "{timestamp}." + raw_body_bytes + signature = "sha256=" + hex(HMAC_SHA256(secret, signing_string)) + +Inbound requests are signed by the caller and verified here; outbound +callbacks are signed here and verified by the caller. The scheme is trivial to +reproduce in any language (see docs/platforms/http-bot.md for JS/curl). +""" + +from __future__ import annotations + +import hashlib +import hmac +import time + +# Header names (kept here so adapter + clients agree on a single source). +HEADER_TIMESTAMP = 'X-LB-Timestamp' +HEADER_SIGNATURE = 'X-LB-Signature' +HEADER_IDEMPOTENCY = 'X-LB-Idempotency-Key' + +# Maximum allowed clock skew between signer and verifier (seconds). +DEFAULT_REPLAY_WINDOW = 300 + + +def compute_signature(secret: str, body: bytes, timestamp: str | int) -> str: + """Compute the ``sha256=`` signature for *body* at *timestamp*. + + Args: + secret: Shared HMAC secret. + body: Raw request body bytes (exactly as sent on the wire). + timestamp: Unix timestamp (seconds) as str or int. + + Returns: + The signature string, e.g. ``sha256=ab12...``. + """ + signing_string = f'{timestamp}.'.encode() + body + digest = hmac.new(secret.encode(), signing_string, hashlib.sha256).hexdigest() + return f'sha256={digest}' + + +def sign(secret: str, body: bytes, timestamp: int | None = None) -> tuple[str, str]: + """Produce ``(timestamp, signature)`` for an outbound request. + + Args: + secret: Shared HMAC secret. + body: Raw request body bytes. + timestamp: Optional fixed timestamp; defaults to ``int(time.time())``. + + Returns: + ``(timestamp_str, signature_str)``. + """ + ts = str(timestamp if timestamp is not None else int(time.time())) + return ts, compute_signature(secret, body, ts) + + +def verify( + secret: str, + body: bytes, + timestamp: str | None, + signature: str | None, + replay_window: int = DEFAULT_REPLAY_WINDOW, +) -> tuple[bool, str]: + """Verify an inbound signature. + + Args: + secret: Shared HMAC secret. + body: Raw request body bytes. + timestamp: Value of the timestamp header. + signature: Value of the signature header. + replay_window: Max allowed skew in seconds. + + Returns: + ``(ok, reason)``. ``reason`` is empty when ``ok`` is True, otherwise a + short machine-friendly cause (``missing_headers`` / ``bad_timestamp`` / + ``expired`` / ``signature_mismatch``). + """ + if not timestamp or not signature: + return False, 'missing_headers' + + try: + ts_int = int(float(timestamp)) + except (ValueError, TypeError): + return False, 'bad_timestamp' + + if abs(int(time.time()) - ts_int) > replay_window: + return False, 'expired' + + expected = compute_signature(secret, body, timestamp) + if not hmac.compare_digest(expected, signature): + return False, 'signature_mismatch' + + return True, '' diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py index 23dcfd90c..bf22dffa5 100644 --- a/src/langbot/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -1239,7 +1239,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot_account_id = config['bot_name'] - bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler) + domain = self._resolve_domain(config) + bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler, domain=domain) api_client = self.build_api_client(config) cipher = AESCipher(config.get('encrypt-key', '')) self.request_app_ticket(api_client, config) @@ -1381,13 +1382,28 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): return None + @staticmethod + def _resolve_domain(config) -> str: + domain = config.get('domain', lark_oapi.FEISHU_DOMAIN) + if domain == 'custom': + domain = config.get('custom_domain', '') + if not domain: + raise ValueError('Custom domain is required when domain is set to "custom"') + return domain.rstrip('/') + def build_api_client(self, config): app_id = config['app_id'] app_secret = config['app_secret'] - api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).build() + domain = self._resolve_domain(config) + api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).domain(domain).build() if 'isv' == config.get('app_type', 'self'): api_client = ( - lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).app_type(lark_oapi.AppType.ISV).build() + lark_oapi.Client.builder() + .app_id(app_id) + .app_secret(app_secret) + .app_type(lark_oapi.AppType.ISV) + .domain(domain) + .build() ) return api_client diff --git a/src/langbot/pkg/platform/sources/lark.yaml b/src/langbot/pkg/platform/sources/lark.yaml index bf2fe3fb8..94509c470 100644 --- a/src/langbot/pkg/platform/sources/lark.yaml +++ b/src/langbot/pkg/platform/sources/lark.yaml @@ -23,6 +23,57 @@ spec: en: https://link.langbot.app/en/platforms/lark ja: https://link.langbot.app/ja/platforms/lark config: + - name: domain + label: + en_US: Platform Domain + zh_Hans: 平台域名 + zh_Hant: 平台域名 + ja_JP: プラットフォームドメイン + description: + en_US: Select the open platform domain. Use Feishu for Chinese mainland, Lark for international + zh_Hans: 选择开放平台域名,国内使用飞书,海外使用 Lark + zh_Hant: 選擇開放平台域名,國內使用飛書,海外使用 Lark + ja_JP: オープンプラットフォームのドメインを選択。中国国内は飛書、海外は Lark を使用 + type: select + options: + - name: https://open.feishu.cn + label: + en_US: Feishu (open.feishu.cn) + zh_Hans: 飞书 (open.feishu.cn) + zh_Hant: 飛書 (open.feishu.cn) + ja_JP: 飛書 (open.feishu.cn) + - name: https://open.larksuite.com + label: + en_US: Lark (open.larksuite.com) + zh_Hans: Lark (open.larksuite.com) + zh_Hant: Lark (open.larksuite.com) + ja_JP: Lark (open.larksuite.com) + - name: custom + label: + en_US: Custom + zh_Hans: 自定义 + zh_Hant: 自定義 + ja_JP: カスタム + required: false + default: https://open.feishu.cn + - name: custom_domain + label: + en_US: Custom Domain + zh_Hans: 自定义域名 + zh_Hant: 自定義域名 + ja_JP: カスタムドメイン + description: + en_US: "Enter the full domain URL, e.g. https://open.example.com" + zh_Hans: "输入完整的域名 URL,例如 https://open.example.com" + zh_Hant: "輸入完整的域名 URL,例如 https://open.example.com" + ja_JP: "完全なドメイン URL を入力(例: https://open.example.com)" + type: string + required: false + default: "" + show_if: + field: domain + operator: eq + value: custom - name: one-click-create label: en_US: One-Click Create App @@ -140,10 +191,10 @@ spec: zh_Hant: 應用類型 ja_JP: アプリタイプ description: - en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview - zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview - zh_Hant: 預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview - ja_JP: デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください + en_US: "Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview" + zh_Hans: "默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview" + zh_Hant: "預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview" + ja_JP: "デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください" type: select options: - name: self diff --git a/src/langbot/pkg/platform/sources/officialaccount.yaml b/src/langbot/pkg/platform/sources/officialaccount.yaml index 9376c3fd6..d09538028 100644 --- a/src/langbot/pkg/platform/sources/officialaccount.yaml +++ b/src/langbot/pkg/platform/sources/officialaccount.yaml @@ -31,6 +31,18 @@ spec: type: webhook-url required: false default: "" + - name: __system.outbound_ips + label: + en_US: IP Whitelist + zh_Hans: IP 白名单 + zh_Hant: IP 白名單 + description: + en_US: Add these outbound IPs of the LangBot server to the IP whitelist in the "Basic Configuration" of the WeChat Official Account platform + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到微信公众平台「基本配置」中的 IP 白名单 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入微信公眾平台「基本配置」中的 IP 白名單 + type: array[string] + required: false + default: [] - name: token label: en_US: Token diff --git a/src/langbot/pkg/platform/sources/qqofficial.yaml b/src/langbot/pkg/platform/sources/qqofficial.yaml index af0e58af3..c9a1bfe79 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.yaml +++ b/src/langbot/pkg/platform/sources/qqofficial.yaml @@ -19,6 +19,18 @@ spec: en: https://link.langbot.app/en/platforms/qqofficial ja: https://link.langbot.app/ja/platforms/qqofficial config: + - name: __system.outbound_ips + label: + en_US: IP Whitelist + zh_Hans: IP 白名单 + zh_Hant: IP 白名單 + description: + en_US: Add these outbound IPs of the LangBot server to the IP whitelist in the development settings of the QQ Open Platform + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到 QQ 开放平台开发设置中的 IP 白名单 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入 QQ 開放平台開發設定中的 IP 白名單 + type: array[string] + required: false + default: [] - name: one-click-bind label: en_US: One-Click QR Binding diff --git a/src/langbot/pkg/platform/sources/web_page_bot_adapter.py b/src/langbot/pkg/platform/sources/web_page_bot_adapter.py index d424debda..fa7f81743 100644 --- a/src/langbot/pkg/platform/sources/web_page_bot_adapter.py +++ b/src/langbot/pkg/platform/sources/web_page_bot_adapter.py @@ -84,6 +84,18 @@ class WebPageBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter ): self.listeners.pop(event_type, None) + async def is_stream_output_supported(self) -> bool: + """Delegate stream output check to ws_adapter.""" + if self._ws_adapter is not None: + return await self._ws_adapter.is_stream_output_supported() + return False + + async def create_message_card(self, message_id: str | int, event: platform_events.MessageEvent) -> bool: + """Delegate create_message_card to ws_adapter.""" + if self._ws_adapter is not None: + return await self._ws_adapter.create_message_card(message_id, event) + return False + async def is_muted(self, group_id: int) -> bool: return False diff --git a/src/langbot/pkg/platform/sources/websocket_adapter.py b/src/langbot/pkg/platform/sources/websocket_adapter.py index 9ffcf04ac..0574292f3 100644 --- a/src/langbot/pkg/platform/sources/websocket_adapter.py +++ b/src/langbot/pkg/platform/sources/websocket_adapter.py @@ -312,12 +312,18 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) async def _process_image_components(self, message_chain_obj: list): """ - 处理消息链中的图片和文件组件,将path转换为base64 + 处理消息链中的图片、语音和文件组件,将 path 转换为 base64 + + Image / Voice / File components uploaded from the web client carry a + storage key in ``path``. Resolve it to a base64 data URI so downstream + stages (multimodal LLM input and the Box sandbox inbox) have a usable + payload, then drop the now-consumed storage object. Args: message_chain_obj: 消息链对象列表 """ import base64 + import mimetypes storage_mgr = self.ap.storage_mgr @@ -325,31 +331,33 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) comp_type = component.get('type', '') comp_path = component.get('path', '') - if not comp_path: + if not comp_path or comp_type not in ('Image', 'Voice', 'File'): continue - if comp_type == 'Image': - try: - file_content = await storage_mgr.storage_provider.load(comp_path) - base64_str = base64.b64encode(file_content).decode('utf-8') + try: + file_content = await storage_mgr.storage_provider.load(comp_path) + base64_str = base64.b64encode(file_content).decode('utf-8') - file_key = comp_path - if file_key.lower().endswith(('.jpg', '.jpeg')): + lowered = comp_path.lower() + if comp_type == 'Image': + if lowered.endswith(('.jpg', '.jpeg')): mime_type = 'image/jpeg' - elif file_key.lower().endswith('.png'): - mime_type = 'image/png' - elif file_key.lower().endswith('.gif'): + elif lowered.endswith('.gif'): mime_type = 'image/gif' - elif file_key.lower().endswith('.webp'): + elif lowered.endswith('.webp'): mime_type = 'image/webp' else: mime_type = 'image/png' + elif comp_type == 'Voice': + mime_type = mimetypes.guess_type(comp_path)[0] or 'audio/wav' + else: # File + mime_type = mimetypes.guess_type(comp_path)[0] or 'application/octet-stream' - component['base64'] = f'data:{mime_type};base64,{base64_str}' - await storage_mgr.storage_provider.delete(comp_path) - component['path'] = '' - except Exception as e: - await self.logger.error(f'Failed to load image file {comp_path}: {e}') + component['base64'] = f'data:{mime_type};base64,{base64_str}' + await storage_mgr.storage_provider.delete(comp_path) + component['path'] = '' + except Exception as e: + await self.logger.error(f'Failed to load {comp_type} file {comp_path}: {e}') async def handle_websocket_message( self, diff --git a/src/langbot/pkg/platform/sources/wecom.yaml b/src/langbot/pkg/platform/sources/wecom.yaml index d232414bc..509fa4e0f 100644 --- a/src/langbot/pkg/platform/sources/wecom.yaml +++ b/src/langbot/pkg/platform/sources/wecom.yaml @@ -32,6 +32,18 @@ spec: type: webhook-url required: false default: "" + - name: __system.outbound_ips + label: + en_US: Trusted IPs + zh_Hans: 企业可信 IP + zh_Hant: 企業可信 IP + description: + en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of your app in the WeCom admin console + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台应用详情页的「企业可信 IP」中 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台應用詳情頁的「企業可信 IP」中 + type: array[string] + required: false + default: [] - name: corpid label: en_US: Corpid diff --git a/src/langbot/pkg/platform/sources/wecombot.yaml b/src/langbot/pkg/platform/sources/wecombot.yaml index 5f65dea67..7e95402ee 100644 --- a/src/langbot/pkg/platform/sources/wecombot.yaml +++ b/src/langbot/pkg/platform/sources/wecombot.yaml @@ -75,6 +75,18 @@ spec: field: enable-webhook operator: eq value: true + - name: __system.outbound_ips + label: + en_US: Trusted IPs + zh_Hans: 企业可信 IP + zh_Hant: 企業可信 IP + description: + en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of the bot configuration in the WeCom admin console + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台智能机器人配置的「企业可信 IP」中 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台智慧機器人設定的「企業可信 IP」中 + type: array[string] + required: false + default: [] - name: Secret label: en_US: Secret diff --git a/src/langbot/pkg/platform/sources/wecomcs.py b/src/langbot/pkg/platform/sources/wecomcs.py index 9af809f7d..ea7d8ef5c 100644 --- a/src/langbot/pkg/platform/sources/wecomcs.py +++ b/src/langbot/pkg/platform/sources/wecomcs.py @@ -2,6 +2,7 @@ from __future__ import annotations import typing import asyncio import traceback +import uuid import datetime import pydantic @@ -182,7 +183,28 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ) async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): - pass + if target_type != 'person': + raise ValueError('WeCom customer service only supports sending messages to person targets') + + open_kfid = self.bot_account_id + external_userid = target_id + if '|' in target_id: + open_kfid, external_userid = target_id.split('|', 1) + if external_userid.startswith('u'): + external_userid = external_userid[1:] + if not open_kfid: + raise ValueError('WeCom customer service open_kfid is required before sending messages') + + content_list = await WecomMessageConverter.yiri2target(message, self.bot) + for content in content_list: + msgid = f'langbot_{uuid.uuid4().hex}' + if content['type'] == 'text': + await self.bot.send_text_msg( + open_kfid=open_kfid, + external_userid=external_userid, + msgid=msgid, + content=content['content'], + ) def set_bot_uuid(self, bot_uuid: str): """设置 bot UUID(用于生成 webhook URL)""" diff --git a/src/langbot/pkg/platform/sources/wecomcs.yaml b/src/langbot/pkg/platform/sources/wecomcs.yaml index 27abc010c..521e7bb0e 100644 --- a/src/langbot/pkg/platform/sources/wecomcs.yaml +++ b/src/langbot/pkg/platform/sources/wecomcs.yaml @@ -31,6 +31,18 @@ spec: type: webhook-url required: false default: "" + - name: __system.outbound_ips + label: + en_US: Trusted IPs + zh_Hans: 企业可信 IP + zh_Hant: 企業可信 IP + description: + en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of WeChat Customer Service in the WeCom admin console + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台微信客服的「企业可信 IP」中 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台微信客服的「企業可信 IP」中 + type: array[string] + required: false + default: [] - name: corpid label: en_US: Corpid diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 9e1b0ea8a..6075d4b68 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -18,6 +18,7 @@ from langbot_plugin.api.entities.builtin.pipeline.query import provider_session from ..core import app from . import handler from ..utils import platform +from ..utils.managed_runtime import ManagedRuntimeConnector from langbot_plugin.runtime.io.controllers.stdio import ( client as stdio_client_controller, ) @@ -39,11 +40,9 @@ class PluginRuntimeNotConnectedError(RuntimeError): """Raised when plugin runtime operations are requested before connection.""" -class PluginRuntimeConnector: +class PluginRuntimeConnector(ManagedRuntimeConnector): """Plugin runtime connector""" - ap: app.Application - handler: handler.RuntimeConnectionHandler handler_task: asyncio.Task @@ -54,10 +53,6 @@ class PluginRuntimeConnector: ctrl: stdio_client_controller.StdioClientController | ws_client_controller.WebSocketClientController - runtime_subprocess_on_windows: asyncio.subprocess.Process | None = None - - runtime_subprocess_on_windows_task: asyncio.Task | None = None - runtime_disconnect_callback: typing.Callable[ [PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None] ] @@ -72,7 +67,7 @@ class PluginRuntimeConnector: [PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None] ], ): - self.ap = ap + super().__init__(ap) self.runtime_disconnect_callback = runtime_disconnect_callback self.is_enable_plugin = self.ap.instance_config.data.get('plugin', {}).get('enable', True) @@ -108,6 +103,16 @@ class PluginRuntimeConnector: self.handler_task = asyncio.create_task(self.handler.run()) _ = await self.handler.ping() + # Push the configured marketplace (Space) URL to the runtime so it + # downloads plugins from the same Space LangBot is bound to, rather + # than relying on the runtime's own env/default. + space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/') + if space_url: + try: + await self.handler.set_runtime_config(cloud_service_url=space_url) + self.ap.logger.info(f'Pushed marketplace URL to plugin runtime: {space_url}') + except Exception as e: + self.ap.logger.warning(f'Failed to push runtime config: {e}') self.ap.logger.info('Connected to plugin runtime.') await self.handler_task @@ -140,19 +145,7 @@ class PluginRuntimeConnector: # We have to launch runtime via cmd but communicate via ws. self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws') - if self.runtime_subprocess_on_windows is None: # only launch once - python_path = sys.executable - env = os.environ.copy() - self.runtime_subprocess_on_windows = await asyncio.create_subprocess_exec( - python_path, - '-m', - 'langbot_plugin.cli.__init__', - 'rt', - env=env, - ) - - # hold the process - self.runtime_subprocess_on_windows_task = asyncio.create_task(self.runtime_subprocess_on_windows.wait()) + await self._start_runtime_subprocess('-m', 'langbot_plugin.cli.__init__', 'rt') ws_url = 'ws://localhost:5400/control/ws' @@ -236,6 +229,94 @@ class PluginRuntimeConnector: return plugin_author, plugin_name + async def _install_mcp_from_marketplace( + self, + mcp_data: dict[str, Any], + task_context: taskmgr.TaskContext | None = None, + ): + """Install an MCP server from marketplace data. + + Marketplace MCP records carry the runtime-ready ``mode`` and + ``extra_args`` directly (the same shape LangBot stores in + ``mcp_servers``), so they are used as-is rather than reconstructed. + For ``stdio`` this preserves ``command``/``args``/``env``/``box``; + for ``http``/``sse`` it preserves ``url``/``headers``/``timeout``/ + ``ssereadtimeout``. + """ + from ..entity.persistence import mcp as persistence_mcp + import uuid + + mode = mcp_data.get('mode') or 'stdio' + extra_args = mcp_data.get('extra_args') or {} + # The MCP transport selection was simplified to two modes: 'stdio' + # (local, Box-sandboxed) and 'remote' (the runtime auto-detects + # Streamable HTTP vs. legacy SSE from the URL). Marketplace records may + # still carry the older 'http'/'sse' modes — normalize them to 'remote' + # so the installed server shows up correctly in the two-option UI. The + # connection args (url/headers/timeout/ssereadtimeout) are preserved and + # consumed by the auto-detecting remote transport regardless. + if mode in ('http', 'sse'): + mode = 'remote' + # Marketplace records carry the rendered README markdown; persist it so + # the detail page Docs tab works offline and without a marketplace round-trip. + readme = mcp_data.get('readme') or '' + # Use __ instead of / to avoid URL routing issues with slashes + name = f'{mcp_data.get("author", "")}__{mcp_data.get("name", "")}' + + # Check if MCP server already exists + existing = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == name) + ) + if existing.scalar_one_or_none(): + self.ap.logger.info(f'MCP server {name} already exists, skipping installation') + return + + # Create MCP server record + server_uuid = str(uuid.uuid4()) + server_data = { + 'uuid': server_uuid, + 'name': name, + 'enable': True, + 'mode': mode, + 'extra_args': extra_args, + 'readme': readme, + } + + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data)) + + # Start the MCP server + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + server_entity = result.first() + if server_entity: + server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity) + if self.ap.tool_mgr.mcp_tool_loader: + mcp_task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) + self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(mcp_task) + + self.ap.logger.info(f'Installed MCP server {name} from marketplace') + + async def _install_skill_from_zip( + self, + file_bytes: bytes, + filename: str, + task_context: taskmgr.TaskContext | None = None, + ): + """Install a skill from marketplace ZIP data.""" + from ..api.http.service.skill import SkillService + + skill_service = SkillService(self.ap) + + self.ap.logger.info(f'Installing skill from marketplace ZIP ({len(file_bytes)} bytes)') + + # Install from ZIP using skill service + result = await skill_service.install_from_zip_upload( + file_bytes=file_bytes, + filename=filename + '.zip', + ) + self.ap.logger.info(f'Skill installed successfully: {result}') + def _build_plugin_startup_failure_message( self, plugin_author: str, @@ -298,6 +379,117 @@ class PluginRuntimeConnector: plugin_author = install_info.get('plugin_author') plugin_name = install_info.get('plugin_name') + if install_source == PluginInstallSource.MARKETPLACE: + # Handle marketplace plugin/mcp/skill installation + plugin_author = install_info.get('plugin_author', '') + plugin_name = install_info.get('plugin_name', '') + space_url = ( + self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app').rstrip('/') + ) + + # Try MCP endpoint first + async with httpx.AsyncClient(trust_env=True, timeout=15) as client: + mcp_resp = await client.get(f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}') + if mcp_resp.status_code == 200: + mcp_data = mcp_resp.json().get('data', {}).get('mcp', {}) + if mcp_data.get('mode'): + # It's an MCP - create server locally + self.ap.logger.info(f'Installing MCP from marketplace: {plugin_author}/{plugin_name}') + if task_context: + task_context.set_current_action('installing mcp server') + await self._install_mcp_from_marketplace(mcp_data, task_context) + # Best-effort install report (bumps marketplace install_count). + try: + await client.post( + f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}/install' + ) + except Exception as report_err: + self.ap.logger.debug(f'Failed to report MCP install: {report_err}') + return + else: + raise Exception(f'MCP {plugin_author}/{plugin_name} has no mode') + elif mcp_resp.status_code == 404: + # Try skill endpoint - download ZIP and install + self.ap.logger.info(f'Trying skill endpoint for: {plugin_author}/{plugin_name}') + if task_context: + task_context.set_current_action('checking skill marketplace') + + # Get skill detail to find version + skill_resp = await client.get( + f'{space_url}/api/v1/marketplace/skills/{plugin_author}/{plugin_name}' + ) + if skill_resp.status_code == 200: + self.ap.logger.info(f'Installing skill from marketplace: {plugin_author}/{plugin_name}') + if task_context: + task_context.set_current_action('installing skill from marketplace') + + # Download the skill ZIP (no version needed - uses latest) + if task_context: + task_context.set_current_action('downloading skill package') + + download_resp = await client.get( + f'{space_url}/api/v1/marketplace/skills/download/{plugin_author}/{plugin_name}' + ) + if download_resp.status_code != 200: + raise Exception( + f'Failed to download skill {plugin_author}/{plugin_name}: {download_resp.status_code}' + ) + + file_bytes = download_resp.content + file_size = len(file_bytes) + self.ap.logger.info(f'Downloaded skill ZIP ({file_size} bytes)') + + # Install skill from ZIP using skill service + await self._install_skill_from_zip(file_bytes, f'{plugin_author}-{plugin_name}', task_context) + return + elif skill_resp.status_code == 404: + # Try plugin endpoint - get versions and download + self.ap.logger.info(f'Trying plugin endpoint for: {plugin_author}/{plugin_name}') + if task_context: + task_context.set_current_action('checking plugin marketplace') + + # Get plugin versions to find latest + versions_resp = await client.get( + f'{space_url}/api/v1/marketplace/plugins/{plugin_author}/{plugin_name}/versions' + ) + if versions_resp.status_code == 200: + versions_data = versions_resp.json().get('data', {}).get('versions', []) + if versions_data: + latest_version = versions_data[0].get('version', '') + if latest_version: + self.ap.logger.info( + f'Installing plugin from marketplace: {plugin_author}/{plugin_name} v{latest_version}' + ) + if task_context: + task_context.set_current_action('downloading plugin package') + + download_resp = await client.get( + f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{latest_version}' + ) + if download_resp.status_code != 200: + raise Exception( + f'Failed to download plugin {plugin_author}/{plugin_name}: {download_resp.status_code}' + ) + + file_bytes = download_resp.content + self._inspect_plugin_package(file_bytes, task_context) + file_key = await self.handler.send_file(file_bytes, 'lbpkg') + install_info['plugin_file_key'] = file_key + self.ap.logger.info(f'Transfered file {file_key} to plugin runtime') + # Continue to install via runtime + else: + raise Exception(f'No version found for plugin {plugin_author}/{plugin_name}') + else: + raise Exception(f'Plugin {plugin_author}/{plugin_name} has no versions') + else: + raise Exception(f'Plugin {plugin_author}/{plugin_name} not found in marketplace') + else: + skill_resp.raise_for_status() + raise Exception(f'Failed to get skill {plugin_author}/{plugin_name}') + else: + mcp_resp.raise_for_status() + raise Exception(f'Failed to get MCP {plugin_author}/{plugin_name}') + if install_source == PluginInstallSource.LOCAL: # transfer file before install file_bytes = install_info['plugin_file'] @@ -506,6 +698,16 @@ class PluginRuntimeConnector: async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str: return await self.handler.get_plugin_readme(plugin_author, plugin_name, language) + async def get_plugin_logs( + self, + plugin_author: str, + plugin_name: str, + limit: int = 200, + level: str | None = None, + ) -> list[dict[str, Any]]: + # Not cached: logs are live and change constantly. + return await self.handler.get_plugin_logs(plugin_author, plugin_name, limit, level) + @alru_cache(ttl=5 * 60) async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]: return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath) @@ -535,6 +737,8 @@ class PluginRuntimeConnector: event_ctx = context.EventContext.from_event(event) if not self.is_enable_plugin: + event_ctx._emitted_plugins = [] + event_ctx._response_sources = [] return event_ctx # Pass include_plugins to runtime for filtering @@ -543,9 +747,21 @@ class PluginRuntimeConnector: ) event_ctx = context.EventContext.model_validate(event_ctx_result['event_context']) + event_ctx._emitted_plugins = event_ctx_result.get('emitted_plugins', []) + if 'response_sources' in event_ctx_result: + event_ctx._response_sources = event_ctx_result['response_sources'] return event_ctx + async def notify_plugin_diagnostic(self, diagnostic: dict[str, Any]) -> None: + """Best-effort diagnostic forwarding to the plugin runtime.""" + if not self.is_enable_plugin: + return + try: + await self.handler.notify_plugin_diagnostic(diagnostic) + except Exception as e: + self.ap.logger.debug(f'Plugin diagnostic forwarding skipped: {e}') + async def list_tools(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]: if not self.is_enable_plugin: return [] @@ -613,13 +829,18 @@ class PluginRuntimeConnector: return await self.handler.retrieve_knowledge(plugin_author, plugin_name, retriever_name, retrieval_context) def dispose(self): - # No need to consider the shutdown on Windows - # for Windows can kill processes and subprocesses chainly - - if self.is_enable_plugin and isinstance(self.ctrl, stdio_client_controller.StdioClientController): + # On non-Windows stdio mode, terminate via the controller's process handle. + # On Windows, the managed subprocess is cleaned up by the base class. + if ( + self.is_enable_plugin + and hasattr(self, 'ctrl') + and isinstance(self.ctrl, stdio_client_controller.StdioClientController) + ): self.ap.logger.info('Terminating plugin runtime process...') self.ctrl.process.terminate() + self._dispose_subprocess() + if self.heartbeat_task is not None: self.heartbeat_task.cancel() self.heartbeat_task = None diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 60922003a..dcfb006b5 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -26,6 +26,15 @@ from ..core import app from ..utils import constants +class _RawAction: + def __init__(self, value: str): + self.value = value + + +def _langbot_to_runtime_action(enum_name: str, fallback_value: str) -> Any: + return getattr(LangBotToRuntimeAction, enum_name, _RawAction(fallback_value)) + + def _make_rag_error_response(error: Exception, error_type: str, **extra_context) -> handler.ActionResponse: """Create a clean error response for RAG operations. @@ -514,6 +523,35 @@ class RuntimeConnectionHandler(handler.Handler): except Exception as e: return _make_rag_error_response(e, 'EmbeddingError', embedding_model_uuid=embedding_model_uuid) + @self.action(PluginToRuntimeAction.INVOKE_RERANK) + async def invoke_rerank(data: dict[str, Any]) -> handler.ActionResponse: + rerank_model_uuid = data['rerank_model_uuid'] + query = data['query'] + documents = data['documents'] + top_k = data.get('top_k') + extra_args = data.get('extra_args', {}) + + try: + rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid) + except ValueError: + return handler.ActionResponse.error( + message=f'Rerank model with rerank_model_uuid {rerank_model_uuid} not found', + ) + + try: + scores = await rerank_model.provider.invoke_rerank( + model=rerank_model, + query=query, + documents=documents[:64], + extra_args=extra_args, + ) + scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True) + if top_k is not None: + scored = scored[: int(top_k)] + return handler.ActionResponse.success(data={'results': scored}) + except Exception as e: + return _make_rag_error_response(e, 'RerankError', rerank_model_uuid=rerank_model_uuid) + @self.action(PluginToRuntimeAction.VECTOR_UPSERT) async def vector_upsert(data: dict[str, Any]) -> handler.ActionResponse: collection_id = data['collection_id'] @@ -779,6 +817,16 @@ class RuntimeConnectionHandler(handler.Handler): timeout=10, ) + async def set_runtime_config(self, cloud_service_url: str) -> dict[str, Any]: + """Push runtime configuration (e.g. marketplace URL) to the runtime.""" + return await self.call_action( + LangBotToRuntimeAction.SET_RUNTIME_CONFIG, + { + 'cloud_service_url': cloud_service_url, + }, + timeout=10, + ) + async def install_plugin( self, install_source: str, install_info: dict[str, Any] ) -> typing.AsyncGenerator[dict[str, Any], None]: @@ -884,6 +932,18 @@ class RuntimeConnectionHandler(handler.Handler): return result + async def notify_plugin_diagnostic(self, diagnostic: dict[str, Any]) -> dict[str, Any]: + """Notify the plugin runtime about a best-effort plugin diagnostic. + + This intentionally uses the raw protocol string instead of a SDK enum so + LangBot can keep running with older langbot-plugin versions. + """ + return await self.call_action( + _langbot_to_runtime_action('PLUGIN_DIAGNOSTIC', 'plugin_diagnostic'), + diagnostic, + timeout=5, + ) + async def list_tools(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]: """List tools""" result = await self.call_action( @@ -943,6 +1003,31 @@ class RuntimeConnectionHandler(handler.Handler): return readme_bytes.decode('utf-8') + async def get_plugin_logs( + self, + plugin_author: str, + plugin_name: str, + limit: int = 200, + level: str | None = None, + ) -> list[dict[str, Any]]: + """Get recent log lines captured from the plugin's stderr.""" + try: + result = await self.call_action( + LangBotToRuntimeAction.GET_PLUGIN_LOGS, + { + 'plugin_author': plugin_author, + 'plugin_name': plugin_name, + 'limit': limit, + 'level': level, + }, + timeout=20, + ) + except Exception: + traceback.print_exc() + return [] + + return result.get('logs', []) + async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]: """Get plugin assets""" result = await self.call_action( diff --git a/src/langbot/pkg/provider/modelmgr/modelmgr.py b/src/langbot/pkg/provider/modelmgr/modelmgr.py index 976f1263b..e3e20e026 100644 --- a/src/langbot/pkg/provider/modelmgr/modelmgr.py +++ b/src/langbot/pkg/provider/modelmgr/modelmgr.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import sqlalchemy import traceback @@ -37,11 +38,41 @@ class ModelManager: self.requester_components = [] self.requester_dict = {} + @staticmethod + def _get_litellm_provider_from_manifest(component: engine.Component | None) -> str | None: + if component is None: + return None + + spec = getattr(component, 'spec', None) or {} + litellm_provider = None + + if isinstance(spec, dict): + litellm_provider = spec.get('litellm_provider') + else: + getter = getattr(spec, 'get', None) + if callable(getter): + try: + litellm_provider = getter('litellm_provider') + except Exception: + litellm_provider = None + + if isinstance(litellm_provider, str) and litellm_provider: + return litellm_provider + return None + async def initialize(self): self.requester_components = self.ap.discover.get_components_by_kind('LLMAPIRequester') requester_dict: dict[str, type[requester.ProviderAPIRequester]] = {} for component in self.requester_components: + # Skip components that use litellm_provider (they will use litellmchat.py instead) + litellm_provider = self._get_litellm_provider_from_manifest(component) + if litellm_provider: + self.ap.logger.debug( + f'Skipping Python class loading for {component.metadata.name} ' + f'(uses litellm_provider={litellm_provider})' + ) + continue requester_dict[component.metadata.name] = component.get_python_component_class() self.requester_dict = requester_dict @@ -54,8 +85,17 @@ class ModelManager: self.ap.logger.info('LangBot Space Models service is disabled, skipping sync.') return + sync_timeout = space_config.get('models_sync_timeout') try: - await self.sync_new_models_from_space() + if sync_timeout: + await asyncio.wait_for( + self.sync_new_models_from_space(), + timeout=float(sync_timeout), + ) + else: + await self.sync_new_models_from_space() + except asyncio.TimeoutError: + self.ap.logger.warning(f'LangBot Space model sync timed out after {sync_timeout}s, skipping startup sync.') except Exception as e: self.ap.logger.warning('Failed to sync new models from LangBot Space, model list may not be updated.') self.ap.logger.warning(f' - Error: {e}') @@ -143,49 +183,83 @@ class ModelManager: # get the latest models from space space_models = await self.ap.space_service.get_models() - exists_llm_models_uuids = [m['uuid'] for m in await self.ap.llm_model_service.get_llm_models()] - exists_embedding_models_uuids = [ - m['uuid'] for m in await self.ap.embedding_models_service.get_embedding_models() - ] + # Index existing models by uuid. Space reuses a model's uuid across + # renames / re-specs (e.g. the uuid that used to be ``claude-opus-4-6`` + # may later become ``claude-opus-4-7``). So for Space-managed models we + # upsert: create when the uuid is new, otherwise update name/abilities/ + # ranking to track Space. Models owned by other providers are never + # touched, even on an (unexpected) uuid collision. + existing_llm_models = {m['uuid']: m for m in await self.ap.llm_model_service.get_llm_models()} + existing_embedding_models = { + m['uuid']: m for m in await self.ap.embedding_models_service.get_embedding_models() + } + + created = 0 + updated = 0 for space_model in space_models: if space_model.category == 'chat': - uuid = space_model.uuid - - if uuid in exists_llm_models_uuids: - continue - - # model will be automatically loaded - await self.ap.llm_model_service.create_llm_model( - { - 'uuid': space_model.uuid, + existing = existing_llm_models.get(space_model.uuid) + if existing is None: + # model will be automatically loaded + await self.ap.llm_model_service.create_llm_model( + { + 'uuid': space_model.uuid, + 'name': space_model.model_id, + 'provider_uuid': space_model_provider.uuid, + 'abilities': space_model.llm_abilities or [], + 'extra_args': {}, + 'prefered_ranking': space_model.featured_order, + }, + preserve_uuid=True, + auto_set_to_default_pipeline=False, + ) + created += 1 + elif existing.get('provider_uuid') == space_model_provider.uuid: + desired = { 'name': space_model.model_id, 'provider_uuid': space_model_provider.uuid, 'abilities': space_model.llm_abilities or [], - 'extra_args': {}, 'prefered_ranking': space_model.featured_order, - }, - preserve_uuid=True, - auto_set_to_default_pipeline=False, - ) + } + if ( + existing.get('name') != desired['name'] + or list(existing.get('abilities') or []) != list(desired['abilities']) + or existing.get('prefered_ranking') != desired['prefered_ranking'] + ): + await self.ap.llm_model_service.update_llm_model(space_model.uuid, dict(desired)) + updated += 1 elif space_model.category == 'embedding': - uuid = space_model.uuid - - if uuid in exists_embedding_models_uuids: - continue - - # model will be automatically loaded - await self.ap.embedding_models_service.create_embedding_model( - { - 'uuid': space_model.uuid, + existing = existing_embedding_models.get(space_model.uuid) + if existing is None: + # model will be automatically loaded + await self.ap.embedding_models_service.create_embedding_model( + { + 'uuid': space_model.uuid, + 'name': space_model.model_id, + 'provider_uuid': space_model_provider.uuid, + 'extra_args': {}, + 'prefered_ranking': space_model.featured_order, + }, + preserve_uuid=True, + ) + created += 1 + elif existing.get('provider_uuid') == space_model_provider.uuid: + desired = { 'name': space_model.model_id, 'provider_uuid': space_model_provider.uuid, - 'extra_args': {}, 'prefered_ranking': space_model.featured_order, - }, - preserve_uuid=True, - ) + } + if ( + existing.get('name') != desired['name'] + or existing.get('prefered_ranking') != desired['prefered_ranking'] + ): + await self.ap.embedding_models_service.update_embedding_model(space_model.uuid, dict(desired)) + updated += 1 + + if created or updated: + self.ap.logger.info(f'Synced models from LangBot Space: {created} added, {updated} updated.') async def init_temporary_runtime_llm_model( self, @@ -202,6 +276,7 @@ class ModelManager: name=model_info.get('name', ''), provider_uuid='', abilities=model_info.get('abilities', []), + context_length=model_info.get('context_length'), extra_args=model_info.get('extra_args', {}), ), provider=runtime_provider, @@ -260,13 +335,37 @@ class ModelManager: else: provider_entity = provider_info - if provider_entity.requester not in self.requester_dict: - raise provider_errors.RequesterNotFoundError(provider_entity.requester) + # Get requester manifest to check for litellm_provider + requester_manifest = self.get_available_requester_manifest_by_name(provider_entity.requester) + litellm_provider = self._get_litellm_provider_from_manifest(requester_manifest) + + # Build config from base_url + config = {'base_url': provider_entity.base_url} + + # Check if requester manifest specifies litellm_provider + if litellm_provider: + from .requesters import litellmchat + + # Use unified LiteLLMRequester with provider prefix + # Map litellm_provider (YAML spec) to custom_llm_provider (config) + config['custom_llm_provider'] = litellm_provider + requester_inst = litellmchat.LiteLLMRequester( + ap=self.ap, + config=config, + ) + self.ap.logger.debug( + f'Using LiteLLMRequester for {provider_entity.requester} ' + f'with custom_llm_provider={config["custom_llm_provider"]}' + ) + else: + # Use original requester class (for backward compatibility) + if provider_entity.requester not in self.requester_dict: + raise provider_errors.RequesterNotFoundError(provider_entity.requester) + requester_inst = self.requester_dict[provider_entity.requester]( + ap=self.ap, + config=config, + ) - requester_inst = self.requester_dict[provider_entity.requester]( - ap=self.ap, - config={'base_url': provider_entity.base_url}, - ) await requester_inst.initialize() token_mgr = token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys or []) @@ -372,6 +471,7 @@ class ModelManager: name=model_info.get('name', ''), provider_uuid=model_info.get('provider_uuid', ''), abilities=model_info.get('abilities', []), + context_length=model_info.get('context_length'), extra_args=model_info.get('extra_args', {}), ) diff --git a/src/langbot/pkg/provider/modelmgr/requester.py b/src/langbot/pkg/provider/modelmgr/requester.py index cb9a4183c..377f7d4a8 100644 --- a/src/langbot/pkg/provider/modelmgr/requester.py +++ b/src/langbot/pkg/provider/modelmgr/requester.py @@ -12,6 +12,19 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.provider.message as provider_message +LLM_USAGE_QUERY_VARIABLE = '_llm_usage' +STREAM_USAGE_QUERY_VARIABLE = '_stream_usage' + + +def _store_llm_usage(query: pipeline_query.Query | None, usage_info: dict | None) -> None: + """Store the latest provider usage on the query for upstream action handlers.""" + if query is None or not usage_info: + return + if query.variables is None: + query.variables = {} + query.variables[LLM_USAGE_QUERY_VARIABLE] = dict(usage_info) + + class RuntimeProvider: """运行时模型提供商""" @@ -67,8 +80,9 @@ class RuntimeProvider: if isinstance(result, tuple): msg, usage_info = result if usage_info: - input_tokens = usage_info.get('input_tokens', 0) - output_tokens = usage_info.get('output_tokens', 0) + _store_llm_usage(query, usage_info) + input_tokens = usage_info.get('prompt_tokens', 0) + output_tokens = usage_info.get('completion_tokens', 0) return msg else: return result @@ -128,7 +142,6 @@ class RuntimeProvider: start_time = time.time() status = 'success' error_message = None - # Note: Stream doesn't easily provide token counts, set to 0 input_tokens = 0 output_tokens = 0 @@ -143,6 +156,16 @@ class RuntimeProvider: remove_think=remove_think, ): yield chunk + # Extract usage from stream if available (stored by LiteLLM requester) + if query: + if query.variables is None: + query.variables = {} + if STREAM_USAGE_QUERY_VARIABLE in query.variables: + usage_info = query.variables[STREAM_USAGE_QUERY_VARIABLE] + _store_llm_usage(query, usage_info) + input_tokens = usage_info.get('prompt_tokens', 0) + output_tokens = usage_info.get('completion_tokens', 0) + del query.variables[STREAM_USAGE_QUERY_VARIABLE] except Exception as e: status = 'error' error_message = str(e) diff --git a/src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.py deleted file mode 100644 index 40a417185..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -import typing -import openai - -from . import chatcmpl - - -class AI302ChatCompletions(chatcmpl.OpenAIChatCompletions): - """302.AI ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://api.302.ai/v1', - 'timeout': 120, - } diff --git a/src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml index e4f70caef..3cfec1982 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: 302.AI icon: 302ai.png spec: + litellm_provider: openai config: - name: base_url label: @@ -22,6 +23,7 @@ spec: type: integer required: true default: 120 + alias: "302ai 302.AI 302 ai 中转 中转站 aggregator gpt claude gemini" support_type: - llm - text-embedding diff --git a/src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.py b/src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.py deleted file mode 100644 index 1428dc887..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.py +++ /dev/null @@ -1,370 +0,0 @@ -from __future__ import annotations - -import typing -import json -import platform -import socket -import anthropic -import httpx - -from .. import errors, requester - -from ....utils import image -import langbot_plugin.api.entities.builtin.resource.tool as resource_tool -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -class AnthropicMessages(requester.ProviderAPIRequester): - """Anthropic Messages API 请求器""" - - client: anthropic.AsyncAnthropic - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://api.anthropic.com', - 'timeout': 120, - } - - async def initialize(self): - # 兼容 Windows 缺失 TCP_KEEPINTVL 和 TCP_KEEPCNT 的问题 - if platform.system() == 'Windows': - if not hasattr(socket, 'TCP_KEEPINTVL'): - socket.TCP_KEEPINTVL = 0 - if not hasattr(socket, 'TCP_KEEPCNT'): - socket.TCP_KEEPCNT = 0 - httpx_client = anthropic._base_client.AsyncHttpxClientWrapper( - base_url=self.requester_cfg['base_url'], - # cast to a valid type because mypy doesn't understand our type narrowing - timeout=typing.cast(httpx.Timeout, self.requester_cfg['timeout']), - limits=anthropic._constants.DEFAULT_CONNECTION_LIMITS, - follow_redirects=True, - trust_env=True, - ) - - self.client = anthropic.AsyncAnthropic( - api_key='', - http_client=httpx_client, - base_url=self.requester_cfg['base_url'], - ) - - async def invoke_llm( - self, - query: pipeline_query.Query, - model: requester.RuntimeLLMModel, - messages: typing.List[provider_message.Message], - funcs: typing.List[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> provider_message.Message: - self.client.api_key = model.provider.token_mgr.get_token() - - args = extra_args.copy() - args['model'] = model.model_entity.name - - # 处理消息 - - # system - system_role_message = None - - for i, m in enumerate(messages): - if m.role == 'system': - system_role_message = m - - break - - if system_role_message: - messages.pop(i) - - if isinstance(system_role_message, provider_message.Message) and isinstance(system_role_message.content, str): - args['system'] = system_role_message.content - - req_messages = [] - - for m in messages: - if m.role == 'tool': - tool_call_id = m.tool_call_id - - req_messages.append( - { - 'role': 'user', - 'content': [ - { - 'type': 'tool_result', - 'tool_use_id': tool_call_id, - 'is_error': False, - 'content': [{'type': 'text', 'text': m.content}], - } - ], - } - ) - - continue - - msg_dict = m.dict(exclude_none=True) - - if isinstance(m.content, str) and m.content.strip() != '': - msg_dict['content'] = [{'type': 'text', 'text': m.content}] - elif isinstance(m.content, list): - for i, ce in enumerate(m.content): - if ce.type == 'image_base64': - image_b64, image_format = await image.extract_b64_and_format(ce.image_base64) - - alter_image_ele = { - 'type': 'image', - 'source': { - 'type': 'base64', - 'media_type': f'image/{image_format}', - 'data': image_b64, - }, - } - msg_dict['content'][i] = alter_image_ele - - if m.tool_calls: - for tool_call in m.tool_calls: - msg_dict['content'].append( - { - 'type': 'tool_use', - 'id': tool_call.id, - 'name': tool_call.function.name, - 'input': json.loads(tool_call.function.arguments), - } - ) - - del msg_dict['tool_calls'] - - req_messages.append(msg_dict) - - args['messages'] = req_messages - - if 'thinking' in args: - args['thinking'] = {'type': 'enabled', 'budget_tokens': 10000} - - if funcs: - tools = await self.ap.tool_mgr.generate_tools_for_anthropic(funcs) - - if tools: - args['tools'] = tools - - try: - resp = await self.client.messages.create(**args) - - args = { - 'content': '', - 'role': resp.role, - } - assert type(resp) is anthropic.types.message.Message - - for block in resp.content: - if not remove_think and block.type == 'thinking': - args['content'] = '\n' + block.thinking + '\n\n' + args['content'] - elif block.type == 'text': - args['content'] += block.text - elif block.type == 'tool_use': - assert type(block) is anthropic.types.tool_use_block.ToolUseBlock - tool_call = provider_message.ToolCall( - id=block.id, - type='function', - function=provider_message.FunctionCall(name=block.name, arguments=json.dumps(block.input)), - ) - if 'tool_calls' not in args: - args['tool_calls'] = [] - args['tool_calls'].append(tool_call) - - return provider_message.Message(**args) - except anthropic.AuthenticationError as e: - raise errors.RequesterError(f'api-key 无效: {e.message}') - except anthropic.BadRequestError as e: - raise errors.RequesterError(str(e.message)) - except anthropic.NotFoundError as e: - if 'model: ' in str(e): - raise errors.RequesterError(f'模型无效: {e.message}') - else: - raise errors.RequesterError(f'请求地址无效: {e.message}') - - async def invoke_llm_stream( - self, - query: pipeline_query.Query, - model: requester.RuntimeLLMModel, - messages: typing.List[provider_message.Message], - funcs: typing.List[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> provider_message.Message: - self.client.api_key = model.provider.token_mgr.get_token() - - args = extra_args.copy() - args['model'] = model.model_entity.name - args['stream'] = True - - # 处理消息 - - # system - system_role_message = None - - for i, m in enumerate(messages): - if m.role == 'system': - system_role_message = m - - break - - if system_role_message: - messages.pop(i) - - if isinstance(system_role_message, provider_message.Message) and isinstance(system_role_message.content, str): - args['system'] = system_role_message.content - - req_messages = [] - - for m in messages: - if m.role == 'tool': - tool_call_id = m.tool_call_id - - req_messages.append( - { - 'role': 'user', - 'content': [ - { - 'type': 'tool_result', - 'tool_use_id': tool_call_id, - 'is_error': False, # 暂时直接写false - 'content': [ - {'type': 'text', 'text': m.content} - ], # 这里要是list包裹,应该是多个返回的情况?type类型好像也可以填其他的,暂时只写text - } - ], - } - ) - - continue - - msg_dict = m.dict(exclude_none=True) - - if isinstance(m.content, str) and m.content.strip() != '': - msg_dict['content'] = [{'type': 'text', 'text': m.content}] - elif isinstance(m.content, list): - for i, ce in enumerate(m.content): - if ce.type == 'image_base64': - image_b64, image_format = await image.extract_b64_and_format(ce.image_base64) - - alter_image_ele = { - 'type': 'image', - 'source': { - 'type': 'base64', - 'media_type': f'image/{image_format}', - 'data': image_b64, - }, - } - msg_dict['content'][i] = alter_image_ele - if isinstance(msg_dict['content'], str) and msg_dict['content'] == '': - msg_dict['content'] = [] # 这里不知道为什么会莫名有个空导致content为字符 - if m.tool_calls: - for tool_call in m.tool_calls: - msg_dict['content'].append( - { - 'type': 'tool_use', - 'id': tool_call.id, - 'name': tool_call.function.name, - 'input': json.loads(tool_call.function.arguments), - } - ) - - del msg_dict['tool_calls'] - - req_messages.append(msg_dict) - if 'thinking' in args: - args['thinking'] = {'type': 'enabled', 'budget_tokens': 10000} - - args['messages'] = req_messages - - if funcs: - tools = await self.ap.tool_mgr.generate_tools_for_anthropic(funcs) - - if tools: - args['tools'] = tools - - try: - role = 'assistant' # 默认角色 - # chunk_idx = 0 - think_started = False - think_ended = False - finish_reason = False - tool_name = '' - tool_id = '' - async for chunk in await self.client.messages.create(**args): - content = '' - tool_call = {'id': None, 'function': {'name': None, 'arguments': None}, 'type': 'function'} - if isinstance( - chunk, anthropic.types.raw_content_block_start_event.RawContentBlockStartEvent - ): # 记录开始 - if chunk.content_block.type == 'tool_use': - if chunk.content_block.name is not None: - tool_name = chunk.content_block.name - if chunk.content_block.id is not None: - tool_id = chunk.content_block.id - - tool_call['function']['name'] = tool_name - tool_call['function']['arguments'] = '' - tool_call['id'] = tool_id - - if not remove_think: - if chunk.content_block.type == 'thinking' and not remove_think: - think_started = True - elif chunk.content_block.type == 'text' and chunk.index != 0 and not remove_think: - think_ended = True - continue - elif isinstance(chunk, anthropic.types.raw_content_block_delta_event.RawContentBlockDeltaEvent): - if chunk.delta.type == 'thinking_delta': - if think_started: - think_started = False - content = '\n' + chunk.delta.thinking - elif remove_think: - continue - else: - content = chunk.delta.thinking - elif chunk.delta.type == 'text_delta': - if think_ended: - think_ended = False - content = '\n\n' + chunk.delta.text - else: - content = chunk.delta.text - elif chunk.delta.type == 'input_json_delta': - tool_call['function']['arguments'] = chunk.delta.partial_json - tool_call['function']['name'] = tool_name - tool_call['id'] = tool_id - elif isinstance(chunk, anthropic.types.raw_content_block_stop_event.RawContentBlockStopEvent): - continue # 记录raw_content_block结束的 - - elif isinstance(chunk, anthropic.types.raw_message_delta_event.RawMessageDeltaEvent): - if chunk.delta.stop_reason == 'end_turn': - finish_reason = True - elif isinstance(chunk, anthropic.types.raw_message_stop_event.RawMessageStopEvent): - continue # 这个好像是完全结束 - else: - # print(chunk) - self.ap.logger.debug(f'anthropic chunk: {chunk}') - continue - - args = { - 'content': content, - 'role': role, - 'is_final': finish_reason, - 'tool_calls': None if tool_call['id'] is None else [tool_call], - } - # if chunk_idx == 0: - # chunk_idx += 1 - # continue - - # assert type(chunk) is anthropic.types.message.Chunk - - yield provider_message.MessageChunk(**args) - - # return llm_entities.Message(**args) - except anthropic.AuthenticationError as e: - raise errors.RequesterError(f'api-key 无效: {e.message}') - except anthropic.BadRequestError as e: - raise errors.RequesterError(str(e.message)) - except anthropic.NotFoundError as e: - if 'model: ' in str(e): - raise errors.RequesterError(f'模型无效: {e.message}') - else: - raise errors.RequesterError(f'请求地址无效: {e.message}') diff --git a/src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.yaml b/src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.yaml index 0ef60d3e5..8600f85a1 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: Anthropic icon: anthropic.svg spec: + litellm_provider: anthropic config: - name: base_url label: @@ -22,6 +23,7 @@ spec: type: integer required: true default: 120 + alias: "anthropic Anthropic 克劳德 claude Claude Opus Sonnet Haiku 安thropic" support_type: - llm provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/baidu.svg b/src/langbot/pkg/provider/modelmgr/requesters/baidu.svg new file mode 100644 index 000000000..a541c95e7 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/baidu.svg @@ -0,0 +1,5 @@ + + + Baidu + ERNIE + diff --git a/src/langbot/pkg/provider/modelmgr/requesters/baiduchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/baiduchatcmpl.yaml new file mode 100644 index 000000000..33af36d56 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/baiduchatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: baidu-chat-completions + label: + en_US: Baidu ERNIE + zh_Hans: 百度文心一言 + icon: baidu.svg +spec: + litellm_provider: openai + config: + - name: base_url + label: + en_US: Base URL + zh_Hans: 基础 URL + type: string + required: true + default: https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop + - name: timeout + label: + en_US: Timeout + zh_Hans: 超时时间 + type: integer + required: true + default: 120 + alias: "baidu Baidu 百度 千帆 qianfan wenxin 文心 文心一言 ernie ERNIE bce embedding bce-reranker" + support_type: + - llm + - text-embedding + - rerank + provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.py deleted file mode 100644 index 9da6e1b43..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.py +++ /dev/null @@ -1,242 +0,0 @@ -from __future__ import annotations - -import typing -import dashscope -import openai - -from . import modelscopechatcmpl -from .. import requester -import langbot_plugin.api.entities.builtin.resource.tool as resource_tool -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -class BailianChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions): - """阿里云百炼大模型平台 ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://dashscope.aliyuncs.com/compatible-mode/v1', - 'timeout': 120, - } - - async def _closure_stream( - self, - query: pipeline_query.Query, - req_messages: list[dict], - use_model: requester.RuntimeLLMModel, - use_funcs: list[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]: - self.client.api_key = use_model.provider.token_mgr.get_token() - - args = {} - args['model'] = use_model.model_entity.name - - if use_funcs: - tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs) - - if tools: - args['tools'] = tools - - # 设置此次请求中的messages - messages = req_messages.copy() - - is_use_dashscope_call = False # 是否使用阿里原生库调用 - is_enable_multi_model = True # 是否支持多轮对话 - use_time_num = 0 # 模型已调用次数,防止存在多文件时重复调用 - use_time_ids = [] # 已调用的ID列表 - message_id = 0 # 记录消息序号 - - for msg in messages: - # print(msg) - if 'content' in msg and isinstance(msg['content'], list): - for me in msg['content']: - if me['type'] == 'image_base64': - me['image_url'] = {'url': me['image_base64']} - me['type'] = 'image_url' - del me['image_base64'] - elif me['type'] == 'file_url' and '.' in me.get('file_name', ''): - # 1. 视频文件推理 - # https://bailian.console.aliyun.com/?tab=doc#/doc/?type=model&url=2845871 - file_type = me.get('file_name').lower().split('.')[-1] - if file_type in ['mp4', 'avi', 'mkv', 'mov', 'flv', 'wmv']: - me['type'] = 'video_url' - me['video_url'] = {'url': me['file_url']} - del me['file_url'] - del me['file_name'] - use_time_num += 1 - use_time_ids.append(message_id) - is_enable_multi_model = False - # 2. 语音文件识别, 无法通过openai的audio字段传递,暂时不支持 - # https://bailian.console.aliyun.com/?tab=doc#/doc/?type=model&url=2979031 - elif file_type in [ - 'aac', - 'amr', - 'aiff', - 'flac', - 'm4a', - 'mp3', - 'mpeg', - 'ogg', - 'opus', - 'wav', - 'webm', - 'wma', - ]: - me['audio'] = me['file_url'] - me['type'] = 'audio' - del me['file_url'] - del me['type'] - del me['file_name'] - is_use_dashscope_call = True - use_time_num += 1 - use_time_ids.append(message_id) - is_enable_multi_model = False - message_id += 1 - - # 使用列表推导式,保留不在 use_time_ids[:-1] 中的元素,仅保留最后一个多媒体消息 - if not is_enable_multi_model and use_time_num > 1: - messages = [msg for idx, msg in enumerate(messages) if idx not in use_time_ids[:-1]] - - if not is_enable_multi_model: - messages = [msg for msg in messages if 'resp_message_id' not in msg] - - args['messages'] = messages - args['stream'] = True - - # 流式处理状态 - # tool_calls_map: dict[str, provider_message.ToolCall] = {} - chunk_idx = 0 - thinking_started = False - thinking_ended = False - role = 'assistant' # 默认角色 - - if is_use_dashscope_call: - response = dashscope.MultiModalConversation.call( - # 若没有配置环境变量,请用百炼API Key将下行替换为:api_key = "sk-xxx" - api_key=use_model.provider.token_mgr.get_token(), - model=use_model.model_entity.name, - messages=messages, - result_format='message', - asr_options={ - # "language": "zh", # 可选,若已知音频的语种,可通过该参数指定待识别语种,以提升识别准确率 - 'enable_lid': True, - 'enable_itn': False, - }, - stream=True, - ) - content_length_list = [] - previous_length = 0 # 记录上一次的内容长度 - for res in response: - chunk = res['output'] - # 解析 chunk 数据 - if hasattr(chunk, 'choices') and chunk.choices: - choice = chunk.choices[0] - delta_content = choice['message'].content[0]['text'] - finish_reason = choice['finish_reason'] - content_length_list.append(len(delta_content)) - else: - delta_content = '' - finish_reason = None - - # 跳过空的第一个 chunk(只有 role 没有内容) - if chunk_idx == 0 and not delta_content: - chunk_idx += 1 - continue - - # 检查 content_length_list 是否有足够的数据 - if len(content_length_list) >= 2: - now_content = delta_content[previous_length : content_length_list[-1]] - previous_length = content_length_list[-1] # 更新上一次的长度 - else: - now_content = delta_content # 第一次循环时直接使用 delta_content - previous_length = len(delta_content) # 更新上一次的长度 - - # 构建 MessageChunk - 只包含增量内容 - chunk_data = { - 'role': role, - 'content': now_content if now_content else None, - 'is_final': bool(finish_reason) and finish_reason != 'null', - } - - # 移除 None 值 - chunk_data = {k: v for k, v in chunk_data.items() if v is not None} - yield provider_message.MessageChunk(**chunk_data) - chunk_idx += 1 - else: - async for chunk in self._req_stream(args, extra_body=extra_args): - # 解析 chunk 数据 - if hasattr(chunk, 'choices') and chunk.choices: - choice = chunk.choices[0] - delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {} - finish_reason = getattr(choice, 'finish_reason', None) - else: - delta = {} - finish_reason = None - - # 从第一个 chunk 获取 role,后续使用这个 role - if 'role' in delta and delta['role']: - role = delta['role'] - - # 获取增量内容 - delta_content = delta.get('content', '') - reasoning_content = delta.get('reasoning_content', '') - - # 处理 reasoning_content - if reasoning_content: - # accumulated_reasoning += reasoning_content - # 如果设置了 remove_think,跳过 reasoning_content - if remove_think: - chunk_idx += 1 - continue - - # 第一次出现 reasoning_content,添加 开始标签 - if not thinking_started: - thinking_started = True - delta_content = '\n' + reasoning_content - else: - # 继续输出 reasoning_content - delta_content = reasoning_content - elif thinking_started and not thinking_ended and delta_content: - # reasoning_content 结束,normal content 开始,添加 结束标签 - thinking_ended = True - delta_content = '\n\n' + delta_content - - # 处理工具调用增量 - if delta.get('tool_calls'): - for tool_call in delta['tool_calls']: - if tool_call['id'] != '': - tool_id = tool_call['id'] - if tool_call['function']['name'] is not None: - tool_name = tool_call['function']['name'] - - if tool_call['type'] is None: - tool_call['type'] = 'function' - tool_call['id'] = tool_id - tool_call['function']['name'] = tool_name - tool_call['function']['arguments'] = ( - '' if tool_call['function']['arguments'] is None else tool_call['function']['arguments'] - ) - - # 跳过空的第一个 chunk(只有 role 没有内容) - if chunk_idx == 0 and not delta_content and not reasoning_content and not delta.get('tool_calls'): - chunk_idx += 1 - continue - - # 构建 MessageChunk - 只包含增量内容 - chunk_data = { - 'role': role, - 'content': delta_content if delta_content else None, - 'tool_calls': delta.get('tool_calls'), - 'is_final': bool(finish_reason), - } - - # 移除 None 值 - chunk_data = {k: v for k, v in chunk_data.items() if v is not None} - - yield provider_message.MessageChunk(**chunk_data) - chunk_idx += 1 - # return diff --git a/src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.yaml index fc5998c42..75b97b7fb 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: 阿里云百炼 icon: bailian.png spec: + litellm_provider: openai config: - name: base_url label: @@ -22,8 +23,10 @@ spec: type: integer required: true default: 120 + alias: "bailian 百炼 阿里 阿里云 aliyun alibaba dashscope 通义 通义千问 qwen Qwen tongyi gte-rerank text-embedding-v" support_type: - llm + - text-embedding - rerank provider_category: maas execution: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.py deleted file mode 100644 index e63e362bf..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.py +++ /dev/null @@ -1,702 +0,0 @@ -from __future__ import annotations - -import asyncio -import typing - -import openai -import openai.types.chat.chat_completion as chat_completion_module -import httpx - -from .. import errors, requester -import langbot_plugin.api.entities.builtin.resource.tool as resource_tool -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -class OpenAIChatCompletions(requester.ProviderAPIRequester): - """OpenAI ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://api.openai.com/v1', - 'timeout': 120, - } - - async def initialize(self): - self.client = openai.AsyncClient( - api_key=self.init_api_key, - base_url=self.requester_cfg['base_url'].replace(' ', ''), - timeout=self.requester_cfg['timeout'], - http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']), - ) - - def _mask_api_key(self, api_key: str | None) -> str: - if not api_key: - return '' - if len(api_key) <= 8: - return '****' - return f'{api_key[:4]}...{api_key[-4:]}' - - def _infer_model_type(self, model_id: str) -> str: - normalized_model_id = (model_id or '').lower() - embedding_keywords = ( - 'embedding', - 'embed', - 'bge-', - 'e5-', - 'm3e', - 'gte-', - 'multilingual-e5', - 'text-embedding', - ) - return 'embedding' if any(keyword in normalized_model_id for keyword in embedding_keywords) else 'llm' - - def _infer_model_abilities(self, item: dict[str, typing.Any], model_id: str) -> list[str]: - normalized_model_id = (model_id or '').lower() - abilities: set[str] = set() - - def _flatten(value: typing.Any) -> list[str]: - if value is None: - return [] - if isinstance(value, str): - return [value.lower()] - if isinstance(value, dict): - flattened: list[str] = [] - for nested_value in value.values(): - flattened.extend(_flatten(nested_value)) - return flattened - if isinstance(value, (list, tuple, set)): - flattened: list[str] = [] - for nested_value in value: - flattened.extend(_flatten(nested_value)) - return flattened - return [str(value).lower()] - - capability_tokens = _flatten(item.get('capabilities')) - capability_tokens.extend(_flatten(item.get('modalities'))) - capability_tokens.extend(_flatten(item.get('input_modalities'))) - capability_tokens.extend(_flatten(item.get('output_modalities'))) - capability_tokens.extend(_flatten(item.get('supported_generation_methods'))) - capability_tokens.extend(_flatten(item.get('supported_parameters'))) - capability_tokens.extend(_flatten(item.get('architecture'))) - - combined_tokens = capability_tokens + [normalized_model_id] - - vision_keywords = ( - 'vision', - 'image', - 'file', - 'video', - 'multimodal', - 'vl', - 'ocr', - 'omni', - ) - function_call_keywords = ( - 'function', - 'tool', - 'tools', - 'tool_choice', - 'tool_call', - 'tool-use', - 'tool_use', - ) - - if any(any(keyword in token for keyword in vision_keywords) for token in combined_tokens): - abilities.add('vision') - - if any(any(keyword in token for keyword in function_call_keywords) for token in combined_tokens): - abilities.add('func_call') - - return sorted(abilities) - - def _normalize_modalities(self, value: typing.Any) -> list[str]: - normalized: list[str] = [] - - def _collect(item: typing.Any): - if item is None: - return - if isinstance(item, str): - for part in item.replace('->', ',').replace('+', ',').split(','): - token = part.strip().lower() - if token and token not in normalized: - normalized.append(token) - return - if isinstance(item, dict): - for nested in item.values(): - _collect(nested) - return - if isinstance(item, (list, tuple, set)): - for nested in item: - _collect(nested) - return - - _collect(value) - return normalized - - def _extract_scan_metadata(self, item: dict[str, typing.Any], model_id: str) -> dict[str, typing.Any]: - display_name = item.get('name') - if not isinstance(display_name, str) or not display_name.strip() or display_name == model_id: - display_name = '' - - description = item.get('description') - if not isinstance(description, str) or not description.strip(): - description = '' - - context_length = item.get('context_length') - if context_length is None and isinstance(item.get('top_provider'), dict): - context_length = item['top_provider'].get('context_length') - - if not isinstance(context_length, int): - try: - context_length = int(context_length) if context_length is not None else None - except (TypeError, ValueError): - context_length = None - - input_modalities = self._normalize_modalities(item.get('input_modalities')) - output_modalities = self._normalize_modalities(item.get('output_modalities')) - - if isinstance(item.get('architecture'), dict): - if not input_modalities: - input_modalities = self._normalize_modalities(item['architecture'].get('input_modalities')) - if not output_modalities: - output_modalities = self._normalize_modalities(item['architecture'].get('output_modalities')) - - owned_by = item.get('owned_by') - if not isinstance(owned_by, str) or not owned_by.strip(): - owned_by = '' - - return { - 'display_name': display_name or None, - 'description': description or None, - 'context_length': context_length, - 'owned_by': owned_by or None, - 'input_modalities': input_modalities, - 'output_modalities': output_modalities, - } - - async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]: - headers = {} - if api_key: - headers['Authorization'] = f'Bearer {api_key}' - - models_url = f'{self.requester_cfg["base_url"].rstrip("/")}/models' - async with httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']) as client: - response = await client.get(models_url, headers=headers) - response.raise_for_status() - payload = response.json() - - models = [] - for item in payload.get('data', []): - model_id = item.get('id') - if not model_id: - continue - models.append( - { - 'id': model_id, - 'name': model_id, - 'type': self._infer_model_type(model_id), - 'abilities': self._infer_model_abilities(item, model_id), - **self._extract_scan_metadata(item, model_id), - } - ) - - models.sort(key=lambda item: (item['type'] != 'llm', item['name'].lower())) - return { - 'models': models, - 'debug': { - 'request': { - 'method': 'GET', - 'url': models_url, - 'headers': { - 'Authorization': f'Bearer {self._mask_api_key(api_key)}' if api_key else '', - }, - }, - 'response': payload, - }, - } - - async def _req( - self, - args: dict, - extra_body: dict = {}, - ) -> chat_completion_module.ChatCompletion: - return await self.client.chat.completions.create(**args, extra_body=extra_body) - - async def _req_stream( - self, - args: dict, - extra_body: dict = {}, - ): - async for chunk in await self.client.chat.completions.create(**args, extra_body=extra_body): - yield chunk - - async def _make_msg( - self, - chat_completion: chat_completion_module.ChatCompletion, - remove_think: bool = False, - ) -> provider_message.Message: - if not isinstance(chat_completion, chat_completion_module.ChatCompletion): - raise TypeError(f'Expected ChatCompletion, got {type(chat_completion).__name__}: {chat_completion[:16]}') - - chatcmpl_message = chat_completion.choices[0].message.model_dump() - - # 确保 role 字段存在且不为 None - if 'role' not in chatcmpl_message or chatcmpl_message['role'] is None: - chatcmpl_message['role'] = 'assistant' - - # 处理思维链 - content = chatcmpl_message.get('content', '') - reasoning_content = chatcmpl_message.get('reasoning_content', None) - - processed_content, _ = await self._process_thinking_content( - content=content, reasoning_content=reasoning_content, remove_think=remove_think - ) - - chatcmpl_message['content'] = processed_content - - # 移除 reasoning_content 字段,避免传递给 Message - if 'reasoning_content' in chatcmpl_message: - del chatcmpl_message['reasoning_content'] - - message = provider_message.Message(**chatcmpl_message) - - return message - - async def _process_thinking_content( - self, - content: str, - reasoning_content: str = None, - remove_think: bool = False, - ) -> tuple[str, str]: - """处理思维链内容 - - Args: - content: 原始内容 - reasoning_content: reasoning_content 字段内容 - remove_think: 是否移除思维链 - - Returns: - (处理后的内容, 提取的思维链内容) - """ - thinking_content = '' - - # 1. 从 reasoning_content 提取思维链 - if reasoning_content: - thinking_content = reasoning_content - - # 2. 从 content 中提取 标签内容 - if content and '' in content and '' in content: - import re - - think_pattern = r'(.*?)' - think_matches = re.findall(think_pattern, content, re.DOTALL) - if think_matches: - # 如果已有 reasoning_content,则追加 - if thinking_content: - thinking_content += '\n' + '\n'.join(think_matches) - else: - thinking_content = '\n'.join(think_matches) - # 移除 content 中的 标签 - content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip() - - # 3. 根据 remove_think 参数决定是否保留思维链 - if remove_think: - return content, '' - else: - # 如果有思维链内容,将其以 格式添加到 content 开头 - if thinking_content: - content = f'\n{thinking_content}\n\n{content}'.strip() - return content, thinking_content - - async def _closure_stream( - self, - query: pipeline_query.Query, - req_messages: list[dict], - use_model: requester.RuntimeLLMModel, - use_funcs: list[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> provider_message.MessageChunk: - self.client.api_key = use_model.provider.token_mgr.get_token() - - args = {} - args['model'] = use_model.model_entity.name - - if use_funcs: - tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs) - if tools: - args['tools'] = tools - - # 设置此次请求中的messages - messages = req_messages.copy() - - # 检查vision - for msg in messages: - if 'content' in msg and isinstance(msg['content'], list): - for me in msg['content']: - if me['type'] == 'image_base64': - me['image_url'] = {'url': me['image_base64']} - me['type'] = 'image_url' - del me['image_base64'] - - args['messages'] = messages - args['stream'] = True - - # 流式处理状态 - # tool_calls_map: dict[str, provider_message.ToolCall] = {} - chunk_idx = 0 - thinking_started = False - thinking_ended = False - role = 'assistant' # 默认角色 - tool_id = '' - tool_name = '' - # accumulated_reasoning = '' # 仅用于判断何时结束思维链 - - async for chunk in self._req_stream(args, extra_body=extra_args): - # 解析 chunk 数据 - - if hasattr(chunk, 'choices') and chunk.choices: - choice = chunk.choices[0] - delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {} - - finish_reason = getattr(choice, 'finish_reason', None) - else: - delta = {} - finish_reason = None - # 从第一个 chunk 获取 role,后续使用这个 role - if 'role' in delta and delta['role']: - role = delta['role'] - - # 获取增量内容 - delta_content = delta.get('content', '') - reasoning_content = delta.get('reasoning_content', '') - - # 处理 reasoning_content - if reasoning_content: - # accumulated_reasoning += reasoning_content - # 如果设置了 remove_think,跳过 reasoning_content - if remove_think: - chunk_idx += 1 - continue - - # 第一次出现 reasoning_content,添加 开始标签 - if not thinking_started: - thinking_started = True - delta_content = '\n' + reasoning_content - else: - # 继续输出 reasoning_content - delta_content = reasoning_content - elif thinking_started and not thinking_ended and delta_content: - # reasoning_content 结束,normal content 开始,添加 结束标签 - thinking_ended = True - delta_content = '\n\n' + delta_content - - # 处理 content 中已有的 标签(如果需要移除) - # if delta_content and remove_think and '' in delta_content: - # import re - # - # # 移除 标签及其内容 - # delta_content = re.sub(r'.*?', '', delta_content, flags=re.DOTALL) - - # 处理工具调用增量 - # delta_tool_calls = None - if delta.get('tool_calls'): - for tool_call in delta['tool_calls']: - if tool_call['id'] and tool_call['function']['name']: - tool_id = tool_call['id'] - tool_name = tool_call['function']['name'] - else: - tool_call['id'] = tool_id - tool_call['function']['name'] = tool_name - if tool_call['type'] is None: - tool_call['type'] = 'function' - - # 跳过空的第一个 chunk(只有 role 没有内容) - if chunk_idx == 0 and not delta_content and not reasoning_content and not delta.get('tool_calls'): - chunk_idx += 1 - continue - # 构建 MessageChunk - 只包含增量内容 - chunk_data = { - 'role': role, - 'content': delta_content if delta_content else None, - 'tool_calls': delta.get('tool_calls'), - 'is_final': bool(finish_reason), - } - - # 移除 None 值 - chunk_data = {k: v for k, v in chunk_data.items() if v is not None} - - yield provider_message.MessageChunk(**chunk_data) - chunk_idx += 1 - - async def _closure( - self, - query: pipeline_query.Query, - req_messages: list[dict], - use_model: requester.RuntimeLLMModel, - use_funcs: list[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> tuple[provider_message.Message, dict]: - self.client.api_key = use_model.provider.token_mgr.get_token() - - args = {} - args['model'] = use_model.model_entity.name - - if use_funcs: - tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs) - - if tools: - args['tools'] = tools - - # 设置此次请求中的messages - messages = req_messages.copy() - - # 检查vision - for msg in messages: - if 'content' in msg and isinstance(msg['content'], list): - for me in msg['content']: - if me['type'] == 'image_base64': - me['image_url'] = {'url': me['image_base64']} - me['type'] = 'image_url' - del me['image_base64'] - - args['messages'] = messages - - # 发送请求 - - resp = await self._req(args, extra_body=extra_args) - # 处理请求结果 - message = await self._make_msg(resp, remove_think) - - # Extract token usage from response - usage_info = {} - if hasattr(resp, 'usage') and resp.usage: - usage_info['input_tokens'] = resp.usage.prompt_tokens or 0 - usage_info['output_tokens'] = resp.usage.completion_tokens or 0 - usage_info['total_tokens'] = resp.usage.total_tokens or 0 - - return message, usage_info - - async def invoke_llm( - self, - query: pipeline_query.Query, - model: requester.RuntimeLLMModel, - messages: typing.List[provider_message.Message], - funcs: typing.List[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> tuple[provider_message.Message, dict]: - """Invoke LLM and return message with usage info""" - req_messages = [] # req_messages 仅用于类内,外部同步由 query.messages 进行 - for m in messages: - msg_dict = m.dict(exclude_none=True) - content = msg_dict.get('content') - if isinstance(content, list): - # 检查 content 列表中是否每个部分都是文本 - if all(isinstance(part, dict) and part.get('type') == 'text' for part in content): - # 将所有文本部分合并为一个字符串 - msg_dict['content'] = '\n'.join(part['text'] for part in content) - req_messages.append(msg_dict) - - try: - msg, usage_info = await self._closure( - query=query, - req_messages=req_messages, - use_model=model, - use_funcs=funcs, - extra_args=extra_args, - remove_think=remove_think, - ) - return msg, usage_info - except asyncio.TimeoutError: - raise errors.RequesterError('请求超时') - except openai.BadRequestError as e: - error_message = str(e.message) if hasattr(e, 'message') else str(e) - if 'context_length_exceeded' in str(e): - raise errors.RequesterError(f'上文过长,请重置会话: {error_message}') - else: - raise errors.RequesterError(f'请求参数错误: {error_message}') - except openai.AuthenticationError as e: - error_message = str(e.message) if hasattr(e, 'message') else str(e) - raise errors.RequesterError(f'无效的 api-key: {error_message}') - except openai.NotFoundError as e: - error_message = str(e.message) if hasattr(e, 'message') else str(e) - raise errors.RequesterError(f'请求路径错误: {error_message}') - except openai.RateLimitError as e: - error_message = str(e.message) if hasattr(e, 'message') else str(e) - raise errors.RequesterError(f'请求过于频繁或余额不足: {error_message}') - except openai.APIConnectionError as e: - error_message = f'连接错误: {str(e)}' - raise errors.RequesterError(error_message) - except openai.APIError as e: - error_message = str(e.message) if hasattr(e, 'message') else str(e) - raise errors.RequesterError(f'请求错误: {error_message}') - - async def invoke_embedding( - self, - model: requester.RuntimeEmbeddingModel, - input_text: list[str], - extra_args: dict[str, typing.Any] = {}, - ) -> tuple[list[list[float]], dict]: - """调用 Embedding API, returns (embeddings, usage_info)""" - self.client.api_key = model.provider.token_mgr.get_token() - - args = { - 'model': model.model_entity.name, - 'input': input_text, - } - - if model.model_entity.extra_args: - args.update(model.model_entity.extra_args) - - args.update(extra_args) - - try: - resp = await self.client.embeddings.create(**args) - - # Extract usage info - usage_info = {} - if hasattr(resp, 'usage') and resp.usage: - usage_info['prompt_tokens'] = resp.usage.prompt_tokens or 0 - usage_info['total_tokens'] = resp.usage.total_tokens or 0 - - return [d.embedding for d in resp.data], usage_info - except asyncio.TimeoutError: - raise errors.RequesterError('请求超时') - except openai.BadRequestError as e: - raise errors.RequesterError(f'请求参数错误: {e.message}') - - async def invoke_llm_stream( - self, - query: pipeline_query.Query, - model: requester.RuntimeLLMModel, - messages: typing.List[provider_message.Message], - funcs: typing.List[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> provider_message.MessageChunk: - req_messages = [] # req_messages 仅用于类内,外部同步由 query.messages 进行 - for m in messages: - msg_dict = m.dict(exclude_none=True) - content = msg_dict.get('content') - if isinstance(content, list): - # 检查 content 列表中是否每个部分都是文本 - if all(isinstance(part, dict) and part.get('type') == 'text' for part in content): - # 将所有文本部分合并为一个字符串 - msg_dict['content'] = '\n'.join(part['text'] for part in content) - req_messages.append(msg_dict) - - try: - async for item in self._closure_stream( - query=query, - req_messages=req_messages, - use_model=model, - use_funcs=funcs, - extra_args=extra_args, - remove_think=remove_think, - ): - yield item - - except asyncio.TimeoutError: - raise errors.RequesterError('请求超时') - except openai.BadRequestError as e: - if 'context_length_exceeded' in e.message: - raise errors.RequesterError(f'上文过长,请重置会话: {e.message}') - else: - raise errors.RequesterError(f'请求参数错误: {e.message}') - except openai.AuthenticationError as e: - raise errors.RequesterError(f'无效的 api-key: {e.message}') - except openai.NotFoundError as e: - raise errors.RequesterError(f'请求路径错误: {e.message}') - except openai.RateLimitError as e: - raise errors.RequesterError(f'请求过于频繁或余额不足: {e.message}') - except openai.APIError as e: - raise errors.RequesterError(f'请求错误: {e.message}') - - async def invoke_rerank( - self, - model: requester.RuntimeRerankModel, - query: str, - documents: typing.List[str], - extra_args: dict[str, typing.Any] = {}, - ) -> typing.List[dict]: - """Standard /rerank endpoint (Jina/Cohere/SiliconFlow/Voyage/DashScope compatible) - - Supports extra_args from model.extra_args: - - rerank_url: full URL override (e.g. "https://dashscope.aliyuncs.com/compatible-api/v1/reranks") - - rerank_path: path override appended to base_url (e.g. "reranks" instead of default "rerank") - - Any other fields are merged into the request payload. - """ - api_key = model.provider.token_mgr.get_token() - base_url = self.requester_cfg.get('base_url', '').rstrip('/') - timeout = self.requester_cfg.get('timeout', 120) - - merged_args = {} - if model.model_entity.extra_args: - merged_args.update(model.model_entity.extra_args) - if extra_args: - merged_args.update(extra_args) - - rerank_url = merged_args.pop('rerank_url', None) - rerank_path = merged_args.pop('rerank_path', 'rerank') - if not rerank_url: - rerank_url = f'{base_url}/{rerank_path}' - - headers = { - 'Content-Type': 'application/json', - 'Authorization': f'Bearer {api_key}', - } - - payload = { - 'model': model.model_entity.name, - 'query': query, - 'documents': documents[:64], - 'top_n': min(len(documents), 64), - } - - if merged_args: - payload.update(merged_args) - - try: - async with httpx.AsyncClient(trust_env=True, timeout=timeout) as client: - resp = await client.post(rerank_url, headers=headers, json=payload) - resp.raise_for_status() - data = resp.json() - - results = self._parse_rerank_response(data) - - if results: - scores = [r.get('relevance_score', 0.0) for r in results] - min_score = min(scores) - max_score = max(scores) - if max_score - min_score > 1e-6: - for r in results: - r['relevance_score'] = (r['relevance_score'] - min_score) / (max_score - min_score) - - return results - except httpx.HTTPStatusError as e: - raise errors.RequesterError(f'Rerank request failed: {e.response.status_code} - {e.response.text}') - except httpx.TimeoutException: - raise errors.RequesterError('Rerank request timed out') - except Exception as e: - raise errors.RequesterError(f'Rerank request error: {str(e)}') - - @staticmethod - def _parse_rerank_response(data: dict) -> typing.List[dict]: - """Parse rerank response from various providers. - - Handles: - - Jina/Cohere/SiliconFlow: {"results": [{"index", "relevance_score"}]} - - Voyage AI: {"data": [{"index", "relevance_score"}]} - - DashScope: {"output": {"results": [{"index", "relevance_score"}]}} - """ - if 'results' in data: - return data['results'] - if 'data' in data: - return data['data'] - if 'output' in data and isinstance(data['output'], dict): - return data['output'].get('results', []) - return [] diff --git a/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.yaml index 21bd6a05b..526721b0e 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: OpenAI icon: openai.svg spec: + litellm_provider: openai config: - name: base_url label: @@ -22,10 +23,10 @@ spec: type: integer required: true default: 120 + alias: "openai OpenAI 欧派 gpt GPT ChatGPT chatgpt o1 o3 o4 text-embedding 通用 openai兼容 compatible" support_type: - llm - text-embedding - - rerank provider_category: manufacturer execution: python: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/chromaembed.yaml b/src/langbot/pkg/provider/modelmgr/requesters/chromaembed.yaml index 396b8c16c..51f2f8212 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/chromaembed.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/chromaembed.yaml @@ -12,6 +12,7 @@ metadata: icon: chroma.svg spec: config: [] + alias: "chroma Chroma 向量 vector embedding 嵌入 chromadb" support_type: - text-embedding provider_category: builtin diff --git a/src/langbot/pkg/provider/modelmgr/requesters/coherererank.yaml b/src/langbot/pkg/provider/modelmgr/requesters/coherererank.yaml index f1ca209b7..e67651bc9 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/coherererank.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/coherererank.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: Cohere icon: cohere.svg spec: + litellm_provider: cohere config: - name: base_url label: @@ -22,6 +23,7 @@ spec: type: integer required: true default: 120 + alias: "cohere Cohere rerank 重排 reranker rerank-english rerank-multilingual command" support_type: - rerank provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.py deleted file mode 100644 index d272e721c..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -import typing -import openai - -from . import chatcmpl - - -class CompShareChatCompletions(chatcmpl.OpenAIChatCompletions): - """CompShare ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://api.modelverse.cn/v1', - 'timeout': 120, - } diff --git a/src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml index 92fcafdc3..843ac9be9 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: 优云智算 icon: compshare.png spec: + litellm_provider: openai config: - name: base_url label: @@ -22,8 +23,11 @@ spec: type: integer required: true default: 120 + alias: "compshare 优刻得 ucloud UCloud 算力 共享算力 GPU" support_type: - llm + - text-embedding + - rerank provider_category: maas execution: python: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.py deleted file mode 100644 index 5bcbd40c5..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -import typing - -from . import chatcmpl -from .. import errors, requester -import langbot_plugin.api.entities.builtin.resource.tool as resource_tool -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -class DeepseekChatCompletions(chatcmpl.OpenAIChatCompletions): - """Deepseek ChatCompletion API 请求器""" - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://api.deepseek.com', - 'timeout': 120, - } - - async def _closure( - self, - query: pipeline_query.Query, - req_messages: list[dict], - use_model: requester.RuntimeLLMModel, - use_funcs: list[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> tuple[provider_message.Message, dict]: - self.client.api_key = use_model.provider.token_mgr.get_token() - - args = {} - args['model'] = use_model.model_entity.name - - if use_funcs: - tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs) - - if tools: - args['tools'] = tools - - # 设置此次请求中的messages - messages = req_messages - - # deepseek 不支持多模态,把content都转换成纯文字 - for m in messages: - if 'content' in m and isinstance(m['content'], list): - m['content'] = ' '.join([c['text'] for c in m['content'] if 'text' in c]) - - args['messages'] = messages - - # 发送请求 - resp = await self._req(args, extra_body=extra_args) - - # print(resp) - - if resp is None: - raise errors.RequesterError('接口返回为空,请确定模型提供商服务是否正常') - # 处理请求结果 - message = await self._make_msg(resp, remove_think) - - # Extract token usage from response - usage_info = {} - if hasattr(resp, 'usage') and resp.usage: - usage_info['input_tokens'] = resp.usage.prompt_tokens or 0 - usage_info['output_tokens'] = resp.usage.completion_tokens or 0 - usage_info['total_tokens'] = resp.usage.total_tokens or 0 - - return message, usage_info diff --git a/src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.yaml index 8ef1fcf9e..466046708 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: DeepSeek icon: deepseek.svg spec: + litellm_provider: deepseek config: - name: base_url label: @@ -22,6 +23,7 @@ spec: type: integer required: true default: 120 + alias: "deepseek DeepSeek 深度求索 深度 求索 dpsk v3 r1 deepseek-chat deepseek-reasoner" support_type: - llm provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/doubao.svg b/src/langbot/pkg/provider/modelmgr/requesters/doubao.svg new file mode 100644 index 000000000..e47c72326 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/doubao.svg @@ -0,0 +1,4 @@ + + + 豆包 + diff --git a/src/langbot/pkg/provider/modelmgr/requesters/doubaochatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/doubaochatcmpl.yaml new file mode 100644 index 000000000..b6cb72c9c --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/doubaochatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: doubao-chat-completions + label: + en_US: ByteDance Doubao + zh_Hans: 字节豆包 + icon: doubao.svg +spec: + litellm_provider: openai + config: + - name: base_url + label: + en_US: Base URL + zh_Hans: 基础 URL + type: string + required: true + default: https://ark.cn-beijing.volces.com/api/v3 + - name: timeout + label: + en_US: Timeout + zh_Hans: 超时时间 + type: integer + required: true + default: 120 + alias: "doubao 豆包 字节 字节跳动 bytedance volcengine 火山 火山引擎 ark 方舟 seed" + support_type: + - llm + - text-embedding + - rerank + provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.py deleted file mode 100644 index 956b49f65..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.py +++ /dev/null @@ -1,205 +0,0 @@ -from __future__ import annotations - -import typing -import httpx - -from . import chatcmpl - -import uuid - -from .. import requester -import langbot_plugin.api.entities.builtin.provider.message as provider_message -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.resource.tool as resource_tool - - -class GeminiChatCompletions(chatcmpl.OpenAIChatCompletions): - """Google Gemini API 请求器""" - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://generativelanguage.googleapis.com/v1beta/openai', - 'timeout': 120, - } - - async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]: - models_url = 'https://generativelanguage.googleapis.com/v1beta/models' - params = {'key': api_key} if api_key else {} - - all_models: list[dict[str, typing.Any]] = [] - next_page_token = '' - last_payload: dict[str, typing.Any] = {} - - async with httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']) as client: - while True: - request_params = dict(params) - if next_page_token: - request_params['pageToken'] = next_page_token - - response = await client.get(models_url, params=request_params) - response.raise_for_status() - payload = response.json() - last_payload = payload - - for item in payload.get('models', []): - model_name = item.get('name', '') - model_id = model_name.replace('models/', '', 1) - if not model_id: - continue - - supported_methods = item.get('supportedGenerationMethods', []) or [] - if 'embedContent' in supported_methods and 'generateContent' not in supported_methods: - model_type = 'embedding' - else: - model_type = 'llm' - - all_models.append( - { - 'id': model_id, - 'name': model_id, - 'type': model_type, - 'abilities': self._infer_model_abilities(item, model_id), - 'display_name': item.get('displayName') or None, - 'description': item.get('description') or None, - 'context_length': item.get('inputTokenLimit'), - 'input_modalities': self._normalize_modalities(item.get('inputModalities')), - 'output_modalities': self._normalize_modalities(item.get('outputModalities')), - } - ) - - next_page_token = payload.get('nextPageToken', '') - if not next_page_token: - break - - all_models.sort(key=lambda item: (item['type'] != 'llm', item['name'].lower())) - return { - 'models': all_models, - 'debug': { - 'request': { - 'method': 'GET', - 'url': models_url, - 'query': {'key': self._mask_api_key(api_key)} if api_key else {}, - }, - 'response': last_payload, - }, - } - - async def _closure_stream( - self, - query: pipeline_query.Query, - req_messages: list[dict], - use_model: requester.RuntimeLLMModel, - use_funcs: list[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> provider_message.MessageChunk: - self.client.api_key = use_model.provider.token_mgr.get_token() - - args = {} - args['model'] = use_model.model_entity.name - - if use_funcs: - tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs) - if tools: - args['tools'] = tools - - # 设置此次请求中的messages - messages = req_messages.copy() - - # 检查vision - for msg in messages: - if 'content' in msg and isinstance(msg['content'], list): - for me in msg['content']: - if me['type'] == 'image_base64': - me['image_url'] = {'url': me['image_base64']} - me['type'] = 'image_url' - del me['image_base64'] - - args['messages'] = messages - args['stream'] = True - - # 流式处理状态 - # tool_calls_map: dict[str, provider_message.ToolCall] = {} - chunk_idx = 0 - thinking_started = False - thinking_ended = False - role = 'assistant' # 默认角色 - tool_id = '' - tool_name = '' - # accumulated_reasoning = '' # 仅用于判断何时结束思维链 - - async for chunk in self._req_stream(args, extra_body=extra_args): - # 解析 chunk 数据 - - if hasattr(chunk, 'choices') and chunk.choices: - choice = chunk.choices[0] - delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {} - - finish_reason = getattr(choice, 'finish_reason', None) - else: - delta = {} - finish_reason = None - # 从第一个 chunk 获取 role,后续使用这个 role - if 'role' in delta and delta['role']: - role = delta['role'] - - # 获取增量内容 - delta_content = delta.get('content', '') - reasoning_content = delta.get('reasoning_content', '') - - # 处理 reasoning_content - if reasoning_content: - # accumulated_reasoning += reasoning_content - # 如果设置了 remove_think,跳过 reasoning_content - if remove_think: - chunk_idx += 1 - continue - - # 第一次出现 reasoning_content,添加 开始标签 - if not thinking_started: - thinking_started = True - delta_content = '\n' + reasoning_content - else: - # 继续输出 reasoning_content - delta_content = reasoning_content - elif thinking_started and not thinking_ended and delta_content: - # reasoning_content 结束,normal content 开始,添加 结束标签 - thinking_ended = True - delta_content = '\n\n' + delta_content - - # 处理 content 中已有的 标签(如果需要移除) - # if delta_content and remove_think and '' in delta_content: - # import re - # - # # 移除 标签及其内容 - # delta_content = re.sub(r'.*?', '', delta_content, flags=re.DOTALL) - - # 处理工具调用增量 - # delta_tool_calls = None - if delta.get('tool_calls'): - for tool_call in delta['tool_calls']: - if tool_call['id'] == '' and tool_id == '': - tool_id = str(uuid.uuid4()) - if tool_call['function']['name']: - tool_name = tool_call['function']['name'] - tool_call['id'] = tool_id - tool_call['function']['name'] = tool_name - if tool_call['type'] is None: - tool_call['type'] = 'function' - - # 跳过空的第一个 chunk(只有 role 没有内容) - if chunk_idx == 0 and not delta_content and not reasoning_content and not delta.get('tool_calls'): - chunk_idx += 1 - continue - # 构建 MessageChunk - 只包含增量内容 - chunk_data = { - 'role': role, - 'content': delta_content if delta_content else None, - 'tool_calls': delta.get('tool_calls'), - 'is_final': bool(finish_reason), - } - - # 移除 None 值 - chunk_data = {k: v for k, v in chunk_data.items() if v is not None} - - yield provider_message.MessageChunk(**chunk_data) - chunk_idx += 1 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.yaml index fdebe9b95..68a81a8b3 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: Google Gemini icon: gemini.svg spec: + litellm_provider: gemini config: - name: base_url label: @@ -22,8 +23,10 @@ spec: type: integer required: true default: 120 + alias: "gemini Gemini 谷歌 google Google 双子座 bard flash pro text-embedding-004" support_type: - llm + - text-embedding provider_category: manufacturer execution: python: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.py deleted file mode 100644 index 4e295e9fc..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - - -import typing - -from . import ppiochatcmpl - - -class GiteeAIChatCompletions(ppiochatcmpl.PPIOChatCompletions): - """Gitee AI ChatCompletions API 请求器""" - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://ai.gitee.com/v1', - 'timeout': 120, - } diff --git a/src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.yaml index b7b158a7a..d5b7ef3fc 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: Gitee AI icon: giteeai.svg spec: + litellm_provider: openai config: - name: base_url label: @@ -22,6 +23,7 @@ spec: type: integer required: true default: 120 + alias: "gitee Gitee 码云 gitee-ai gitee ai serverless bge embedding rerank" support_type: - llm - text-embedding diff --git a/src/langbot/pkg/provider/modelmgr/requesters/groq.svg b/src/langbot/pkg/provider/modelmgr/requesters/groq.svg new file mode 100644 index 000000000..7c84ba683 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/groq.svg @@ -0,0 +1,4 @@ + + + Groq + diff --git a/src/langbot/pkg/provider/modelmgr/requesters/groqchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/groqchatcmpl.yaml new file mode 100644 index 000000000..d51367473 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/groqchatcmpl.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: groq-chat-completions + label: + en_US: Groq + zh_Hans: Groq + icon: groq.svg +spec: + litellm_provider: groq + config: + - name: base_url + label: + en_US: Base URL + zh_Hans: 基础 URL + type: string + required: true + default: https://api.groq.com/openai/v1 + - name: timeout + label: + en_US: Timeout + zh_Hans: 超时时间 + type: integer + required: true + default: 120 + alias: "groq Groq 高速 llama mixtral 推理加速 lpu" + support_type: + - llm + provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/iflytek.svg b/src/langbot/pkg/provider/modelmgr/requesters/iflytek.svg new file mode 100644 index 000000000..7498b149f --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/iflytek.svg @@ -0,0 +1,5 @@ + + + iFlytek + Spark + diff --git a/src/langbot/pkg/provider/modelmgr/requesters/iflytekchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/iflytekchatcmpl.yaml new file mode 100644 index 000000000..decc22228 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/iflytekchatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: iflytek-chat-completions + label: + en_US: iFlytek Spark + zh_Hans: 讯飞星火 + icon: iflytek.svg +spec: + litellm_provider: openai + config: + - name: base_url + label: + en_US: Base URL + zh_Hans: 基础 URL + type: string + required: true + default: https://spark-api-open.xf-yun.com/v1 + - name: timeout + label: + en_US: Timeout + zh_Hans: 超时时间 + type: integer + required: true + default: 120 + alias: "iflytek 讯飞 科大讯飞 星火 spark xinghuo xunfei 讯飞星火" + support_type: + - llm + - text-embedding + - rerank + provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/jiekouaichatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/jiekouaichatcmpl.py deleted file mode 100644 index 305ae21fd..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/jiekouaichatcmpl.py +++ /dev/null @@ -1,208 +0,0 @@ -from __future__ import annotations - -import openai -import typing - -from . import chatcmpl -from .. import requester -import openai.types.chat.chat_completion as chat_completion -import re -import langbot_plugin.api.entities.builtin.provider.message as provider_message -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.resource.tool as resource_tool - - -class JieKouAIChatCompletions(chatcmpl.OpenAIChatCompletions): - """接口 AI ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://api.jiekou.ai/openai', - 'timeout': 120, - } - - is_think: bool = False - - async def _make_msg( - self, - chat_completion: chat_completion.ChatCompletion, - remove_think: bool, - ) -> provider_message.Message: - chatcmpl_message = chat_completion.choices[0].message.model_dump() - # print(chatcmpl_message.keys(), chatcmpl_message.values()) - - # 确保 role 字段存在且不为 None - if 'role' not in chatcmpl_message or chatcmpl_message['role'] is None: - chatcmpl_message['role'] = 'assistant' - - reasoning_content = chatcmpl_message['reasoning_content'] if 'reasoning_content' in chatcmpl_message else None - - # deepseek的reasoner模型 - chatcmpl_message['content'] = await self._process_thinking_content( - chatcmpl_message['content'], reasoning_content, remove_think - ) - - # 移除 reasoning_content 字段,避免传递给 Message - if 'reasoning_content' in chatcmpl_message: - del chatcmpl_message['reasoning_content'] - - message = provider_message.Message(**chatcmpl_message) - - return message - - async def _process_thinking_content( - self, - content: str, - reasoning_content: str = None, - remove_think: bool = False, - ) -> tuple[str, str]: - """处理思维链内容 - - Args: - content: 原始内容 - reasoning_content: reasoning_content 字段内容 - remove_think: 是否移除思维链 - - Returns: - 处理后的内容 - """ - if remove_think: - content = re.sub(r'.*?', '', content, flags=re.DOTALL) - else: - if reasoning_content is not None: - content = '\n' + reasoning_content + '\n\n' + content - return content - - async def _make_msg_chunk( - self, - delta: dict[str, typing.Any], - idx: int, - ) -> provider_message.MessageChunk: - # 处理流式chunk和完整响应的差异 - # print(chat_completion.choices[0]) - - # 确保 role 字段存在且不为 None - if 'role' not in delta or delta['role'] is None: - delta['role'] = 'assistant' - - reasoning_content = delta['reasoning_content'] if 'reasoning_content' in delta else None - - delta['content'] = '' if delta['content'] is None else delta['content'] - # print(reasoning_content) - - # deepseek的reasoner模型 - - if reasoning_content is not None: - delta['content'] += reasoning_content - - message = provider_message.MessageChunk(**delta) - - return message - - async def _closure_stream( - self, - query: pipeline_query.Query, - req_messages: list[dict], - use_model: requester.RuntimeLLMModel, - use_funcs: list[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]: - self.client.api_key = use_model.provider.token_mgr.get_token() - - args = {} - args['model'] = use_model.model_entity.name - - if use_funcs: - tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs) - - if tools: - args['tools'] = tools - - # 设置此次请求中的messages - messages = req_messages.copy() - - # 检查vision - for msg in messages: - if 'content' in msg and isinstance(msg['content'], list): - for me in msg['content']: - if me['type'] == 'image_base64': - me['image_url'] = {'url': me['image_base64']} - me['type'] = 'image_url' - del me['image_base64'] - - args['messages'] = messages - args['stream'] = True - - # tool_calls_map: dict[str, provider_message.ToolCall] = {} - chunk_idx = 0 - thinking_started = False - thinking_ended = False - role = 'assistant' # 默认角色 - async for chunk in self._req_stream(args, extra_body=extra_args): - # 解析 chunk 数据 - if hasattr(chunk, 'choices') and chunk.choices: - choice = chunk.choices[0] - delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {} - finish_reason = getattr(choice, 'finish_reason', None) - else: - delta = {} - finish_reason = None - - # 从第一个 chunk 获取 role,后续使用这个 role - if 'role' in delta and delta['role']: - role = delta['role'] - - # 获取增量内容 - delta_content = delta.get('content', '') - # reasoning_content = delta.get('reasoning_content', '') - - if remove_think: - if delta['content'] is not None: - if '' in delta['content'] and not thinking_started and not thinking_ended: - thinking_started = True - continue - elif delta['content'] == r'' and not thinking_ended: - thinking_ended = True - continue - elif thinking_ended and delta['content'] == '\n\n' and thinking_started: - thinking_started = False - continue - elif thinking_started and not thinking_ended: - continue - - # delta_tool_calls = None - if delta.get('tool_calls'): - for tool_call in delta['tool_calls']: - if tool_call['id'] and tool_call['function']['name']: - tool_id = tool_call['id'] - tool_name = tool_call['function']['name'] - - if tool_call['id'] is None: - tool_call['id'] = tool_id - if tool_call['function']['name'] is None: - tool_call['function']['name'] = tool_name - if tool_call['function']['arguments'] is None: - tool_call['function']['arguments'] = '' - if tool_call['type'] is None: - tool_call['type'] = 'function' - - # 跳过空的第一个 chunk(只有 role 没有内容) - if chunk_idx == 0 and not delta_content and not delta.get('tool_calls'): - chunk_idx += 1 - continue - - # 构建 MessageChunk - 只包含增量内容 - chunk_data = { - 'role': role, - 'content': delta_content if delta_content else None, - 'tool_calls': delta.get('tool_calls'), - 'is_final': bool(finish_reason), - } - - # 移除 None 值 - chunk_data = {k: v for k, v in chunk_data.items() if v is not None} - - yield provider_message.MessageChunk(**chunk_data) - chunk_idx += 1 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/jiekouaichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/jiekouaichatcmpl.yaml index 3c791d733..44aa07742 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/jiekouaichatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/jiekouaichatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: 接口 AI icon: jiekouai.png spec: + litellm_provider: openai config: - name: base_url label: @@ -29,9 +30,11 @@ spec: type: int required: true default: 120 + alias: "jiekouai 接口AI 接口 jiekou ai 中转 中转站 aggregator" support_type: - llm - text-embedding + - rerank provider_category: maas execution: python: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/jinarerank.yaml b/src/langbot/pkg/provider/modelmgr/requesters/jinarerank.yaml index 3b448e385..b94b2f74d 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/jinarerank.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/jinarerank.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: Jina icon: jina.svg spec: + litellm_provider: openai config: - name: base_url label: @@ -22,6 +23,7 @@ spec: type: integer required: true default: 120 + alias: "jina Jina jina-ai jinaai rerank 重排 reranker jina-reranker embedding" support_type: - rerank provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py new file mode 100644 index 000000000..c1b5ae0b6 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py @@ -0,0 +1,851 @@ +"""LiteLLM unified requester for chat, embedding, and rerank.""" + +from __future__ import annotations + +import typing + +import litellm +from litellm import acompletion, aembedding, arerank + +from .. import errors, requester +import langbot_plugin.api.entities.builtin.resource.tool as resource_tool +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.entities.builtin.provider.message as provider_message + + +class LiteLLMRequester(requester.ProviderAPIRequester): + """LiteLLM unified API requester supporting chat, embedding, and rerank.""" + + _EMBEDDING_MODEL_HINTS = ('embedding', 'embed', 'bge-', 'e5-', 'm3e', 'gte-', 'text-embedding') + _RERANK_MODEL_HINTS = ('rerank', 're-rank', 're_rank') + + default_config: dict[str, typing.Any] = { + 'base_url': '', + 'timeout': 120, + 'custom_llm_provider': '', + 'drop_params': False, + 'num_retries': 0, + 'api_version': '', + } + + async def initialize(self): + """Initialize LiteLLM client settings.""" + # LiteLLM doesn't require explicit client initialization + # Configuration is passed per-request via litellm params + pass + + def _build_litellm_model_name(self, model_name: str, custom_llm_provider: str | None = None) -> str: + """Build LiteLLM model name with provider prefix if needed.""" + provider = custom_llm_provider or self.requester_cfg.get('custom_llm_provider', '') + if provider: + # LiteLLM format: provider/model_name + if model_name.startswith(f'{provider}/'): + return model_name + return f'{provider}/{model_name}' + # If no custom provider, assume model_name already includes prefix or is OpenAI-compatible + return model_name + + def _get_custom_llm_provider(self) -> str | None: + return self.requester_cfg.get('custom_llm_provider') or None + + def _safe_litellm_bool_helper(self, helper_name: str, model_name: str) -> bool: + """Call a LiteLLM boolean capability helper without letting metadata gaps fail requests.""" + helper = getattr(litellm, helper_name, None) + if not callable(helper): + return False + + provider = self._get_custom_llm_provider() + candidates: list[tuple[str, str | None]] = [(model_name, provider)] + litellm_model_name = self._build_litellm_model_name(model_name) + if litellm_model_name != model_name: + candidates.append((litellm_model_name, None)) + for metadata_provider in self._metadata_provider_candidates(model_name): + candidates.append((f'{metadata_provider}/{model_name}', None)) + + tried_candidates: set[tuple[str, str | None]] = set() + for candidate_model, candidate_provider in candidates: + candidate_key = (candidate_model, candidate_provider) + if candidate_key in tried_candidates: + continue + tried_candidates.add(candidate_key) + try: + if bool(helper(model=candidate_model, custom_llm_provider=candidate_provider)): + return True + except Exception: + continue + return False + + @staticmethod + def _positive_int(value: typing.Any) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int) and value > 0: + return value + if isinstance(value, str) and value.isdigit(): + parsed_value = int(value) + if parsed_value > 0: + return parsed_value + return None + + def _context_length_from_scan_payload(self, model_payload: dict[str, typing.Any] | None) -> int | None: + if not model_payload: + return None + + for field_name in ('context_length', 'context_window', 'max_context_length'): + context_length = self._positive_int(model_payload.get(field_name)) + if context_length is not None: + return context_length + return None + + def _context_length_from_litellm_model_info(self, model_info: typing.Any) -> int | None: + if isinstance(model_info, dict): + return self._positive_int(model_info.get('max_input_tokens')) + return self._positive_int(getattr(model_info, 'max_input_tokens', None)) + + def _metadata_provider_candidates(self, model_name: str) -> list[str]: + normalized_model_name = (model_name or '').lower() + candidates = [] + if normalized_model_name.startswith(('moonshot-', 'kimi-')): + candidates.append('moonshot') + if normalized_model_name.startswith('deepseek-'): + candidates.append('deepseek') + + base_url = self.requester_cfg.get('base_url', '').lower() + if 'moonshot' in base_url: + candidates.append('moonshot') + if 'deepseek' in base_url: + candidates.append('deepseek') + + deduped_candidates = [] + for candidate in candidates: + if candidate not in deduped_candidates: + deduped_candidates.append(candidate) + return deduped_candidates + + def _known_context_length_fallback(self, model_name: str) -> int | None: + normalized_model_name = (model_name or '').lower() + if normalized_model_name.startswith('deepseek-v4-'): + return 1_000_000 + if normalized_model_name.startswith(('kimi-k2.5', 'kimi-k2.6')): + return 256 * 1024 + if normalized_model_name.startswith('moonshot-v1-8k'): + return 8 * 1024 + if normalized_model_name.startswith('moonshot-v1-32k'): + return 32 * 1024 + if normalized_model_name.startswith('moonshot-v1-128k') or normalized_model_name == 'moonshot-v1-auto': + return 128 * 1024 + return None + + def _safe_context_length(self, model_name: str) -> int | None: + helper = getattr(litellm, 'get_model_info', None) + if not callable(helper): + return self._known_context_length_fallback(model_name) + + candidates = [model_name] + litellm_model_name = self._build_litellm_model_name(model_name) + if litellm_model_name != model_name: + candidates.append(litellm_model_name) + for provider in self._metadata_provider_candidates(model_name): + candidates.append(f'{provider}/{model_name}') + + tried_candidates = [] + for candidate in candidates: + if candidate in tried_candidates: + continue + tried_candidates.append(candidate) + try: + model_info = helper(candidate) + except Exception: + continue + context_length = self._context_length_from_litellm_model_info(model_info) + if context_length is not None: + return context_length + return self._known_context_length_fallback(model_name) + + def _supports_function_calling(self, model_name: str) -> bool: + return self._safe_litellm_bool_helper('supports_function_calling', model_name) + + def _supports_vision(self, model_name: str) -> bool: + return self._safe_litellm_bool_helper('supports_vision', model_name) + + def _infer_model_type(self, model_id: str) -> str: + normalized_id = (model_id or '').lower() + if any(kw in normalized_id for kw in self._RERANK_MODEL_HINTS): + return 'rerank' + if any(kw in normalized_id for kw in self._EMBEDDING_MODEL_HINTS): + return 'embedding' + return 'llm' + + def _enrich_scanned_model( + self, + model_id: str, + model_payload: dict[str, typing.Any] | None = None, + ) -> dict[str, typing.Any]: + model_type = self._infer_model_type(model_id) + scanned_model: dict[str, typing.Any] = { + 'id': model_id, + 'name': model_id, + 'type': model_type, + } + + if model_type == 'llm': + abilities = [] + if self._supports_function_calling(model_id): + abilities.append('func_call') + supports_provider_reported_vision = bool( + model_payload + and (model_payload.get('supports_image_in') is True or model_payload.get('supports_vision') is True) + ) + if supports_provider_reported_vision or self._supports_vision(model_id): + abilities.append('vision') + scanned_model['abilities'] = abilities + + context_length = self._context_length_from_scan_payload(model_payload) + if context_length is None: + context_length = self._safe_context_length(model_id) + if context_length is not None: + scanned_model['context_length'] = context_length + + return scanned_model + + def _convert_messages(self, messages: typing.List[provider_message.Message]) -> list[dict]: + """Convert LangBot messages to LiteLLM/OpenAI format.""" + req_messages = [] + for m in messages: + msg_dict = m.dict(exclude_none=True) + content = msg_dict.get('content') + + if isinstance(content, list): + converted_parts = [] + for part in content: + if isinstance(part, dict) and part.get('type') == 'image_base64': + part['image_url'] = {'url': part['image_base64']} + part['type'] = 'image_url' + del part['image_base64'] + # OpenAI-compatible chat models reject non-image file parts + # (audio/document base64 or url). These originate from Voice / + # File attachments — including ones replayed from conversation + # history — and the agent already accesses their bytes via the + # sandbox. Drop them from the model payload to avoid + # "Invalid user message ... invalid content type=file_base64". + if isinstance(part, dict) and part.get('type') in ('file_base64', 'file_url'): + continue + converted_parts.append(part) + msg_dict['content'] = converted_parts + + req_messages.append(msg_dict) + + return req_messages + + def _process_thinking_content(self, content: str, reasoning_content: str | None, remove_think: bool) -> str: + """Process thinking/reasoning content. + + Args: + content: The main content from response + reasoning_content: Separate reasoning content from model + remove_think: If True, remove thinking markers; if False, preserve them + + Returns: + Processed content string + """ + # Extract and handle thinking tags + if content and 'CRETIRE_REASONING_BEGINk' in content and 'CRETIRE_REASONING_ENDk' in content: + import re + + think_pattern = r'CRETIRE_REASONING_BEGINk(.*?)CRETIRE_REASONING_ENDk' + + if remove_think: + # Remove thinking tags and their content from output + content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip() + # else: preserve thinking content as-is + + # Handle separate reasoning_content field + # Currently we don't include reasoning_content in user-facing output regardless of remove_think + # because it's typically internal model reasoning, not user-visible thinking + return content or '' + + @staticmethod + def _normalize_usage(usage: typing.Any) -> dict: + """Normalize a LiteLLM/OpenAI usage object into a plain token dict. + + Handles several real-world shapes returned by different upstreams: + - object with ``prompt_tokens`` / ``completion_tokens`` / ``total_tokens`` attrs + - dict with the same keys + - missing ``total_tokens`` (derived from prompt + completion) + - ``None`` / partially-populated usage (defaults to 0) + - provider-specific token details, including cache token counters + """ + + def _plain_value(value: typing.Any) -> typing.Any: + if value is None: + return None + if isinstance(value, dict): + return {k: _plain_value(v) for k, v in value.items() if v is not None} + if isinstance(value, (list, tuple)): + return [_plain_value(v) for v in value] + + model_dump = getattr(value, 'model_dump', None) + if callable(model_dump): + try: + dumped = model_dump() + if isinstance(dumped, dict): + return _plain_value(dumped) + except Exception: + pass + + return value + + def _usage_dict(value: typing.Any) -> dict[str, typing.Any]: + if value is None: + return {} + plain = _plain_value(value) + if isinstance(plain, dict): + return plain + + def _is_mock_attr(attr: typing.Any) -> bool: + return type(attr).__module__.startswith('unittest.mock') + + data: dict[str, typing.Any] = {} + for key in ( + 'prompt_tokens', + 'completion_tokens', + 'total_tokens', + 'prompt_tokens_details', + 'completion_tokens_details', + 'cache_creation_input_tokens', + 'cache_read_input_tokens', + 'input_token_details', + 'output_token_details', + ): + attr_value = getattr(value, key, None) + if attr_value is not None and not _is_mock_attr(attr_value): + data[key] = _plain_value(attr_value) + return data + + def _to_int(value: typing.Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + normalized = _usage_dict(usage) + + prompt_tokens = _to_int(normalized.get('prompt_tokens')) + completion_tokens = _to_int(normalized.get('completion_tokens')) + total_tokens = _to_int(normalized.get('total_tokens')) + + # Some providers omit total_tokens in streaming usage; derive it. + if not total_tokens: + total_tokens = prompt_tokens + completion_tokens + + normalized['prompt_tokens'] = prompt_tokens + normalized['completion_tokens'] = completion_tokens + normalized['total_tokens'] = total_tokens + return normalized + + def _extract_usage(self, response) -> dict | None: + """Extract usage info from a non-streaming LiteLLM response.""" + usage = getattr(response, 'usage', None) + if usage is None: + return None + return self._normalize_usage(usage) + + @staticmethod + def _as_dict(value: typing.Any) -> dict: + if value is None: + return {} + if isinstance(value, dict): + return value + if hasattr(value, 'model_dump'): + return value.model_dump() + return {} + + def _normalize_stream_tool_calls( + self, + raw_tool_calls: typing.Any, + tool_call_state: dict[int, dict[str, typing.Any]], + ) -> list[dict] | None: + """Fill OpenAI-style streaming tool-call deltas so MessageChunk can validate them. + + Also preserves provider_specific_fields (e.g., Gemini thought_signature) for + round-tripping to the next request. + """ + if not raw_tool_calls: + return None + + normalized = [] + for fallback_index, raw_tool_call in enumerate(raw_tool_calls): + tool_call = self._as_dict(raw_tool_call) + index = tool_call.get('index') + if not isinstance(index, int): + index = fallback_index + + state = tool_call_state.setdefault( + index, + { + 'id': '', + 'type': 'function', + 'name': '', + 'provider_specific_fields': None, + }, + ) + if tool_call.get('id'): + state['id'] = tool_call['id'] + if tool_call.get('type'): + state['type'] = tool_call['type'] + + # Preserve provider_specific_fields from the raw tool call + if 'provider_specific_fields' in tool_call: + state['provider_specific_fields'] = tool_call['provider_specific_fields'] + + function = self._as_dict(tool_call.get('function')) + if function.get('name'): + state['name'] = function['name'] + + # Also check function-level provider_specific_fields + if 'provider_specific_fields' in function: + # Merge function-level into tool-level, function-level takes precedence + func_psf = function['provider_specific_fields'] + if state['provider_specific_fields']: + merged = {**state['provider_specific_fields'], **func_psf} + state['provider_specific_fields'] = merged + else: + state['provider_specific_fields'] = func_psf + + arguments = function.get('arguments') + if arguments is None: + arguments = '' + elif not isinstance(arguments, str): + arguments = str(arguments) + + # Some OpenAI-compatible providers (notably Ollama's + # /v1/chat/completions) stream a tool-call delta with an `index` and + # a `function` payload but never emit an OpenAI-style `id`. Without + # an id the call used to be dropped here, so the whole tool call + # silently vanished: a tool-only turn then yielded no content and no + # tool call, the stream "completed" with 0 chars, and the chat + # appeared stuck. Synthesize a stable per-index id so named-but-idless + # tool calls survive. Providers that do send ids keep theirs. + if not state['id'] and state['name']: + state['id'] = f'call_{index}' + + if not state['id'] or not state['name']: + continue + + tool_call_dict: dict[str, typing.Any] = { + 'id': state['id'], + 'type': state['type'] or 'function', + 'function': { + 'name': state['name'], + 'arguments': arguments, + }, + } + + # Include provider_specific_fields if present + if state['provider_specific_fields']: + tool_call_dict['provider_specific_fields'] = state['provider_specific_fields'] + + normalized.append(tool_call_dict) + + return normalized or None + + def _build_common_args(self, args: dict, include_retry_params: bool = True) -> dict: + """Apply common requester config to args dict.""" + if self.requester_cfg.get('base_url'): + args['api_base'] = self.requester_cfg['base_url'] + if self.requester_cfg.get('timeout'): + args['timeout'] = self.requester_cfg['timeout'] + if include_retry_params: + if self.requester_cfg.get('drop_params'): + args['drop_params'] = self.requester_cfg['drop_params'] + if self.requester_cfg.get('num_retries'): + args['num_retries'] = self.requester_cfg['num_retries'] + if self.requester_cfg.get('api_version'): + args['api_version'] = self.requester_cfg['api_version'] + return args + + def _handle_litellm_error(self, e: Exception) -> None: + """Convert LiteLLM exceptions to RequesterError. Never returns, always raises.""" + # Check more specific exceptions first (they inherit from base exceptions) + if isinstance(e, litellm.ContextWindowExceededError): + raise errors.RequesterError(f'上下文长度超限: {str(e)}') + if isinstance(e, litellm.BadRequestError): + raise errors.RequesterError(f'请求参数错误: {str(e)}') + if isinstance(e, litellm.AuthenticationError): + raise errors.RequesterError(f'API key 无效: {str(e)}') + if isinstance(e, litellm.NotFoundError): + raise errors.RequesterError(f'模型或路径无效: {str(e)}') + if isinstance(e, litellm.RateLimitError): + raise errors.RequesterError(f'请求过于频繁或余额不足: {str(e)}') + if isinstance(e, litellm.Timeout): + raise errors.RequesterError(f'请求超时: {str(e)}') + if isinstance(e, litellm.APIConnectionError): + raise errors.RequesterError(f'连接错误: {str(e)}') + if isinstance(e, litellm.APIError): + raise errors.RequesterError(f'API 错误: {str(e)}') + raise errors.RequesterError(f'未知错误: {str(e)}') + + async def _build_completion_args( + self, + model: requester.RuntimeLLMModel, + messages: typing.List[provider_message.Message], + funcs: typing.List[resource_tool.LLMTool] = None, + extra_args: dict[str, typing.Any] = {}, + stream: bool = False, + ) -> dict: + """Build common completion arguments for invoke_llm and invoke_llm_stream.""" + req_messages = self._convert_messages(messages) + model_name = self._build_litellm_model_name(model.model_entity.name) + api_key = model.provider.token_mgr.get_token() + + args = { + 'model': model_name, + 'messages': req_messages, + 'api_key': api_key, + } + if stream: + args['stream'] = True + args['stream_options'] = {'include_usage': True} + self._build_common_args(args) + + # Apply model-level extra_args first, then call-level extra_args + if model.model_entity.extra_args: + args.update(model.model_entity.extra_args) + args.update(extra_args) + + if funcs: + tools = await self.ap.tool_mgr.generate_tools_for_openai(funcs) + if tools: + args['tools'] = tools + args.setdefault('tool_choice', 'auto') + + return args + + async def invoke_llm( + self, + query: pipeline_query.Query, + model: requester.RuntimeLLMModel, + messages: typing.List[provider_message.Message], + funcs: typing.List[resource_tool.LLMTool] = None, + extra_args: dict[str, typing.Any] = {}, + remove_think: bool = False, + ) -> tuple[provider_message.Message, dict]: + """Invoke LLM and return message with usage info.""" + args = await self._build_completion_args(model, messages, funcs, extra_args, stream=False) + + try: + response = await acompletion(**args) + + message_data = response.choices[0].message.model_dump() + if 'role' not in message_data or message_data['role'] is None: + message_data['role'] = 'assistant' + + content = message_data.get('content', '') + reasoning_content = message_data.get('reasoning_content', None) + message_data['content'] = self._process_thinking_content(content, reasoning_content, remove_think) + + if 'reasoning_content' in message_data: + del message_data['reasoning_content'] + + message = provider_message.Message(**message_data) + usage_info = self._extract_usage(response) + + return message, usage_info + + except Exception as e: + self._handle_litellm_error(e) + + async def invoke_llm_stream( + self, + query: pipeline_query.Query, + model: requester.RuntimeLLMModel, + messages: typing.List[provider_message.Message], + funcs: typing.List[resource_tool.LLMTool] = None, + extra_args: dict[str, typing.Any] = {}, + remove_think: bool = False, + ) -> provider_message.MessageChunk: + """Invoke LLM streaming and yield chunks.""" + args = await self._build_completion_args(model, messages, funcs, extra_args, stream=True) + + chunk_idx = 0 + role = 'assistant' + tool_call_state: dict[int, dict[str, typing.Any]] = {} + + try: + response = await acompletion(**args) + async for chunk in response: + # Capture usage whenever a chunk carries it. + # + # Important: many OpenAI-compatible gateways (e.g. new-api) and + # providers send the final usage payload in a chunk that STILL + # contains a (empty-delta) choice, not an empty `choices` list. + # The previous implementation only captured usage when `choices` + # was empty, so streamed calls always recorded 0 tokens. + # We therefore capture usage independently of `choices`, and then + # fall through to also process any content this chunk may carry. + if getattr(chunk, 'usage', None): + usage_info = self._normalize_usage(chunk.usage) + if query is not None: + if query.variables is None: + query.variables = {} + query.variables[requester.STREAM_USAGE_QUERY_VARIABLE] = usage_info + + if not hasattr(chunk, 'choices') or not chunk.choices: + continue + + choice = chunk.choices[0] + delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {} + finish_reason = getattr(choice, 'finish_reason', None) + + if 'role' in delta and delta['role']: + role = delta['role'] + + delta_content = delta.get('content', '') + reasoning_content = delta.get('reasoning_content', '') + + # Handle reasoning_content based on remove_think flag + if reasoning_content: + if remove_think: + # Skip reasoning content when remove_think is True + chunk_idx += 1 + continue + else: + # Use reasoning_content as the displayed content + delta_content = reasoning_content + + tool_calls = self._normalize_stream_tool_calls(delta.get('tool_calls'), tool_call_state) + + if chunk_idx == 0 and not delta_content and not tool_calls: + chunk_idx += 1 + continue + + chunk_data: dict[str, typing.Any] = { + 'role': role, + 'content': delta_content if delta_content else None, + 'tool_calls': tool_calls, + 'is_final': bool(finish_reason), + } + + # Preserve provider_specific_fields from delta (e.g., Gemini thought_signatures) + if delta.get('provider_specific_fields'): + chunk_data['provider_specific_fields'] = delta['provider_specific_fields'] + + chunk_data = {k: v for k, v in chunk_data.items() if v is not None} + yield provider_message.MessageChunk(**chunk_data) + chunk_idx += 1 + + except Exception as e: + self._handle_litellm_error(e) + + async def invoke_embedding( + self, + model: requester.RuntimeEmbeddingModel, + input_text: list[str], + extra_args: dict[str, typing.Any] = {}, + ) -> tuple[list[list[float]], dict]: + """Invoke embedding and return vectors with usage info.""" + model_name = self._build_litellm_model_name(model.model_entity.name) + api_key = model.provider.token_mgr.get_token() + + args = { + 'model': model_name, + 'input': input_text, + 'api_key': api_key, + } + self._build_common_args(args, include_retry_params=False) + + if model.model_entity.extra_args: + args.update(model.model_entity.extra_args) + + args.update(extra_args) + + try: + response = await aembedding(**args) + + # LiteLLM returns response.data entries either as objects with an + # `.embedding` attribute or as plain dicts (many OpenAI-compatible + # gateways, e.g. new-api, yield dict-shaped entries). Handle both. + embeddings = [d['embedding'] if isinstance(d, dict) else d.embedding for d in response.data] + usage_info = self._extract_usage(response) + + return embeddings, usage_info + + except Exception as e: + self._handle_litellm_error(e) + + async def invoke_rerank( + self, + model: requester.RuntimeRerankModel, + query: str, + documents: typing.List[str], + extra_args: dict[str, typing.Any] = {}, + ) -> typing.List[dict]: + """Invoke rerank and return relevance scores.""" + model_name = self._build_litellm_model_name(model.model_entity.name) + api_key = model.provider.token_mgr.get_token() + + top_n = min(len(documents), 64) + + provider = self._get_custom_llm_provider() + + try: + # LiteLLM's rerank API does not support the `openai` provider + # (litellm/rerank_api/main.py raises "Unsupported provider: openai"). + # OpenAI-compatible gateways (newapi / one-api / vLLM / Xinference, etc.) + # expose the standard Jina/Cohere-style POST /v1/rerank endpoint, so + # call it directly over HTTP for openai-compatible (or unspecified) providers. + if provider in (None, '', 'openai'): + results = await self._invoke_rerank_openai_compatible( + model_name=model.model_entity.name, + query=query, + documents=documents, + api_key=api_key, + top_n=top_n, + extra_args={**(model.model_entity.extra_args or {}), **extra_args}, + ) + else: + args = { + 'model': model_name, + 'query': query, + 'documents': documents, + 'api_key': api_key, + 'top_n': top_n, + } + self._build_common_args(args, include_retry_params=False) + + if model.model_entity.extra_args: + args.update(model.model_entity.extra_args) + + args.update(extra_args) + + response = await arerank(**args) + + results = [] + for r in response.results: + results.append( + { + 'index': r.get('index', 0), + 'relevance_score': r.get('relevance_score', 0.0), + } + ) + + if results: + scores = [r['relevance_score'] for r in results] + min_score = min(scores) + max_score = max(scores) + if max_score - min_score > 1e-6: + for r in results: + r['relevance_score'] = (r['relevance_score'] - min_score) / (max_score - min_score) + + return results + + except errors.RequesterError: + raise + except Exception as e: + self._handle_litellm_error(e) + + async def _invoke_rerank_openai_compatible( + self, + model_name: str, + query: str, + documents: typing.List[str], + api_key: str, + top_n: int, + extra_args: dict[str, typing.Any] = {}, + ) -> typing.List[dict]: + """Call the standard Jina/Cohere-style POST /v1/rerank endpoint over HTTP. + + Used for OpenAI-compatible gateways where litellm.arerank rejects the + `openai` provider. Returns the same shape as the litellm path: + a list of {'index': int, 'relevance_score': float}. + """ + import httpx + + base_url = (self.requester_cfg.get('base_url') or '').rstrip('/') + if not base_url: + raise errors.RequesterError('Base URL required for rerank') + + timeout = self.requester_cfg.get('timeout', 120) + + headers = {'Content-Type': 'application/json'} + if api_key: + headers['Authorization'] = f'Bearer {api_key}' + + payload: dict[str, typing.Any] = { + 'model': model_name, + 'query': query, + 'documents': documents, + 'top_n': top_n, + } + if extra_args: + payload.update(extra_args) + + rerank_url = f'{base_url}/rerank' + + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(rerank_url, headers=headers, json=payload) + resp.raise_for_status() + data = resp.json() + except httpx.HTTPStatusError as e: + body = '' + try: + body = e.response.text + except Exception: + pass + raise errors.RequesterError(f'rerank 请求失败 (HTTP {e.response.status_code}): {body or str(e)}') + except httpx.HTTPError as e: + raise errors.RequesterError(f'rerank 连接错误: {str(e)}') + + raw_results = data.get('results', []) if isinstance(data, dict) else [] + results = [] + for r in raw_results: + results.append( + { + 'index': r.get('index', 0), + 'relevance_score': r.get('relevance_score', r.get('score', 0.0)) or 0.0, + } + ) + + return results + + async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]: + """Scan models supported by the provider.""" + import httpx + + base_url = self.requester_cfg.get('base_url', '').rstrip('/') + timeout = self.requester_cfg.get('timeout', 120) + + if not base_url: + raise errors.RequesterError('Base URL required for model scanning') + + headers = {} + if api_key: + headers['Authorization'] = f'Bearer {api_key}' + + models_url = f'{base_url}/models' + + try: + async with httpx.AsyncClient(trust_env=True, timeout=timeout) as client: + response = await client.get(models_url, headers=headers) + response.raise_for_status() + payload = response.json() + + models = [] + for item in payload.get('data', []): + model_id = item.get('id') + if not model_id: + continue + + models.append(self._enrich_scanned_model(model_id, item)) + + models.sort(key=lambda x: (x['type'] != 'llm', x['name'].lower())) + + return {'models': models} + + except httpx.HTTPStatusError as e: + raise errors.RequesterError(f'Model scan failed: {e.response.status_code}') + except httpx.TimeoutException: + raise errors.RequesterError('Model scan timeout') + except Exception as e: + raise errors.RequesterError(f'Model scan error: {str(e)}') diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.yaml b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.yaml new file mode 100644 index 000000000..1d5452d50 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.yaml @@ -0,0 +1,65 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: litellm-chat + label: + en_US: LiteLLM (Unified) + zh_Hans: LiteLLM (统一请求器) + icon: litellm.svg +spec: + config: + - name: base_url + label: + en_US: Base URL + zh_Hans: 基础 URL + type: string + required: false + default: '' + - name: timeout + label: + en_US: Timeout + zh_Hans: 超时时间 + type: integer + required: true + default: 120 + - name: custom_llm_provider + label: + en_US: Custom Provider + zh_Hans: 自定义 Provider + type: string + required: false + default: '' + description: + en_US: Force provider type (e.g., anthropic, openai, gemini) + zh_Hans: 强制指定 provider 类型(如 anthropic, openai, gemini) + - name: drop_params + label: + en_US: Drop Unsupported Params + zh_Hans: 丢弃不支持参数 + type: boolean + required: false + default: false + - name: num_retries + label: + en_US: Number of Retries + zh_Hans: 重试次数 + type: integer + required: false + default: 0 + - name: api_version + label: + en_US: API Version + zh_Hans: API 版本 + type: string + required: false + default: '' + alias: "litellm LiteLLM 通用 universal 万能 兼容 compatible proxy 代理 中转" + support_type: + - llm + - text-embedding + - rerank + provider_category: unified +execution: + python: + path: ./litellmchat.py + attr: LiteLLMRequester \ No newline at end of file diff --git a/src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.py deleted file mode 100644 index c9060c1b0..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -import typing -import openai - -from . import chatcmpl - - -class LmStudioChatCompletions(chatcmpl.OpenAIChatCompletions): - """LMStudio ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'http://127.0.0.1:1234/v1', - 'timeout': 120, - } diff --git a/src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.yaml index 81dc82cf8..c1d3ad15e 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: LM Studio icon: lmstudio.webp spec: + litellm_provider: openai config: - name: base_url label: @@ -22,6 +23,7 @@ spec: type: integer required: true default: 120 + alias: "lmstudio LM Studio lm-studio 本地 local 本地部署 self-hosted gguf" support_type: - llm - text-embedding diff --git a/src/langbot/pkg/provider/modelmgr/requesters/mimo.svg b/src/langbot/pkg/provider/modelmgr/requesters/mimo.svg new file mode 100644 index 000000000..5d9b21dc8 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/mimo.svg @@ -0,0 +1,4 @@ + + + MiMo + diff --git a/src/langbot/pkg/provider/modelmgr/requesters/mimochatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/mimochatcmpl.yaml new file mode 100644 index 000000000..e20f95c86 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/mimochatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: mimo-chat-completions + label: + en_US: Xiaomi MiMo + zh_Hans: 小米 MiMo + icon: mimo.svg +spec: + litellm_provider: openai + config: + - name: base_url + label: + en_US: Base URL + zh_Hans: 基础 URL + type: string + required: true + default: https://api.xiaomimimo.com/v1 + - name: timeout + label: + en_US: Timeout + zh_Hans: 超时时间 + type: integer + required: true + default: 120 + alias: "mimo MiMo 小米 xiaomi 小米大模型 xiaomi-mimo" + support_type: + - llm + - text-embedding + - rerank + provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/minimax.svg b/src/langbot/pkg/provider/modelmgr/requesters/minimax.svg new file mode 100644 index 000000000..1afeadc3b --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/minimax.svg @@ -0,0 +1,4 @@ + + + MiniMax + diff --git a/src/langbot/pkg/provider/modelmgr/requesters/minimaxchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/minimaxchatcmpl.yaml new file mode 100644 index 000000000..b0c246c98 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/minimaxchatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: minimax-chat-completions + label: + en_US: MiniMax + zh_Hans: MiniMax + icon: minimax.svg +spec: + litellm_provider: openai + config: + - name: base_url + label: + en_US: Base URL + zh_Hans: 基础 URL + type: string + required: true + default: https://api.minimax.chat/v1 + - name: timeout + label: + en_US: Timeout + zh_Hans: 超时时间 + type: integer + required: true + default: 120 + alias: "minimax MiniMax 名之梦 海螺 hailuo abab embo embedding" + support_type: + - llm + - text-embedding + - rerank + provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/mistral.svg b/src/langbot/pkg/provider/modelmgr/requesters/mistral.svg new file mode 100644 index 000000000..853022d9b --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/mistral.svg @@ -0,0 +1,5 @@ + + + Mistral + AI + diff --git a/src/langbot/pkg/provider/modelmgr/requesters/mistralchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/mistralchatcmpl.yaml new file mode 100644 index 000000000..1ad136867 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/mistralchatcmpl.yaml @@ -0,0 +1,30 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: mistral-chat-completions + label: + en_US: Mistral AI + zh_Hans: Mistral AI + icon: mistral.svg +spec: + litellm_provider: mistral + config: + - name: base_url + label: + en_US: Base URL + zh_Hans: 基础 URL + type: string + required: true + default: https://api.mistral.ai/v1 + - name: timeout + label: + en_US: Timeout + zh_Hans: 超时时间 + type: integer + required: true + default: 120 + alias: "mistral Mistral 米斯特拉尔 mixtral codestral mistral-embed le-chat" + support_type: + - llm + - text-embedding + provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.py deleted file mode 100644 index c98a71d7c..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.py +++ /dev/null @@ -1,561 +0,0 @@ -from __future__ import annotations - -import asyncio -import typing - -import openai -import openai.types.chat.chat_completion as chat_completion -import httpx - -from .. import entities, errors, requester -import langbot_plugin.api.entities.builtin.resource.tool as resource_tool -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -class ModelScopeChatCompletions(requester.ProviderAPIRequester): - """ModelScope ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://api-inference.modelscope.cn/v1', - 'timeout': 120, - } - - async def initialize(self): - self.client = openai.AsyncClient( - api_key=self.init_api_key, - base_url=self.requester_cfg['base_url'], - timeout=self.requester_cfg['timeout'], - http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']), - ) - - def _mask_api_key(self, api_key: str | None) -> str: - if not api_key: - return '' - if len(api_key) <= 8: - return '****' - return f'{api_key[:4]}...{api_key[-4:]}' - - def _infer_model_type(self, model_id: str) -> str: - normalized_model_id = (model_id or '').lower() - embedding_keywords = ( - 'embedding', - 'embed', - 'bge-', - 'e5-', - 'm3e', - 'gte-', - 'multilingual-e5', - 'text-embedding', - ) - return 'embedding' if any(keyword in normalized_model_id for keyword in embedding_keywords) else 'llm' - - def _infer_model_abilities(self, item: dict[str, typing.Any], model_id: str) -> list[str]: - normalized_model_id = (model_id or '').lower() - abilities: set[str] = set() - - def _flatten(value: typing.Any) -> list[str]: - if value is None: - return [] - if isinstance(value, str): - return [value.lower()] - if isinstance(value, dict): - flattened: list[str] = [] - for nested_value in value.values(): - flattened.extend(_flatten(nested_value)) - return flattened - if isinstance(value, (list, tuple, set)): - flattened: list[str] = [] - for nested_value in value: - flattened.extend(_flatten(nested_value)) - return flattened - return [str(value).lower()] - - capability_tokens = _flatten(item.get('capabilities')) - capability_tokens.extend(_flatten(item.get('modalities'))) - capability_tokens.extend(_flatten(item.get('input_modalities'))) - capability_tokens.extend(_flatten(item.get('output_modalities'))) - capability_tokens.extend(_flatten(item.get('supported_generation_methods'))) - capability_tokens.extend(_flatten(item.get('supported_parameters'))) - capability_tokens.extend(_flatten(item.get('architecture'))) - - combined_tokens = capability_tokens + [normalized_model_id] - - vision_keywords = ('vision', 'image', 'file', 'video', 'multimodal', 'vl', 'ocr', 'omni') - function_call_keywords = ('function', 'tool', 'tools', 'tool_choice', 'tool_call', 'tool-use', 'tool_use') - - if any(any(keyword in token for keyword in vision_keywords) for token in combined_tokens): - abilities.add('vision') - - if any(any(keyword in token for keyword in function_call_keywords) for token in combined_tokens): - abilities.add('func_call') - - return sorted(abilities) - - def _normalize_modalities(self, value: typing.Any) -> list[str]: - normalized: list[str] = [] - - def _collect(item: typing.Any): - if item is None: - return - if isinstance(item, str): - for part in item.replace('->', ',').replace('+', ',').split(','): - token = part.strip().lower() - if token and token not in normalized: - normalized.append(token) - return - if isinstance(item, dict): - for nested in item.values(): - _collect(nested) - return - if isinstance(item, (list, tuple, set)): - for nested in item: - _collect(nested) - return - - _collect(value) - return normalized - - def _extract_scan_metadata(self, item: dict[str, typing.Any], model_id: str) -> dict[str, typing.Any]: - display_name = item.get('name') - if not isinstance(display_name, str) or not display_name.strip() or display_name == model_id: - display_name = '' - - description = item.get('description') - if not isinstance(description, str) or not description.strip(): - description = '' - - context_length = item.get('context_length') - if context_length is None and isinstance(item.get('top_provider'), dict): - context_length = item['top_provider'].get('context_length') - - if not isinstance(context_length, int): - try: - context_length = int(context_length) if context_length is not None else None - except (TypeError, ValueError): - context_length = None - - input_modalities = self._normalize_modalities(item.get('input_modalities')) - output_modalities = self._normalize_modalities(item.get('output_modalities')) - - if isinstance(item.get('architecture'), dict): - if not input_modalities: - input_modalities = self._normalize_modalities(item['architecture'].get('input_modalities')) - if not output_modalities: - output_modalities = self._normalize_modalities(item['architecture'].get('output_modalities')) - - owned_by = item.get('owned_by') - if not isinstance(owned_by, str) or not owned_by.strip(): - owned_by = '' - - return { - 'display_name': display_name or None, - 'description': description or None, - 'context_length': context_length, - 'owned_by': owned_by or None, - 'input_modalities': input_modalities, - 'output_modalities': output_modalities, - } - - async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]: - headers = {} - if api_key: - headers['Authorization'] = f'Bearer {api_key}' - - models_url = f'{self.requester_cfg["base_url"].rstrip("/")}/models' - async with httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']) as client: - response = await client.get(models_url, headers=headers) - response.raise_for_status() - payload = response.json() - - models = [] - for item in payload.get('data', []): - model_id = item.get('id') - if not model_id: - continue - models.append( - { - 'id': model_id, - 'name': model_id, - 'type': self._infer_model_type(model_id), - 'abilities': self._infer_model_abilities(item, model_id), - **self._extract_scan_metadata(item, model_id), - } - ) - - models.sort(key=lambda item: (item['type'] != 'llm', item['name'].lower())) - return { - 'models': models, - 'debug': { - 'request': { - 'method': 'GET', - 'url': models_url, - 'headers': { - 'Authorization': f'Bearer {self._mask_api_key(api_key)}' if api_key else '', - }, - }, - 'response': payload, - }, - } - - async def _req( - self, - query: pipeline_query.Query, - args: dict, - extra_body: dict = {}, - remove_think: bool = False, - ) -> list[dict[str, typing.Any]]: - args['stream'] = True - - chunk = None - - pending_content = '' - - tool_calls = [] - - resp_gen: openai.AsyncStream = await self.client.chat.completions.create(**args, extra_body=extra_body) - - chunk_idx = 0 - thinking_started = False - thinking_ended = False - tool_id = '' - tool_name = '' - message_delta = {} - async for chunk in resp_gen: - if not chunk or not chunk.id or not chunk.choices or not chunk.choices[0] or not chunk.choices[0].delta: - continue - - delta = chunk.choices[0].delta.model_dump() if hasattr(chunk.choices[0], 'delta') else {} - reasoning_content = delta.get('reasoning_content') - # 处理 reasoning_content - if reasoning_content: - # accumulated_reasoning += reasoning_content - # 如果设置了 remove_think,跳过 reasoning_content - if remove_think: - chunk_idx += 1 - continue - - # 第一次出现 reasoning_content,添加 开始标签 - if not thinking_started: - thinking_started = True - pending_content += '\n' + reasoning_content - else: - # 继续输出 reasoning_content - pending_content += reasoning_content - elif thinking_started and not thinking_ended and delta.get('content'): - # reasoning_content 结束,normal content 开始,添加 结束标签 - thinking_ended = True - pending_content += '\n\n' + delta.get('content') - - if delta.get('content') is not None: - pending_content += delta.get('content') - - if delta.get('tool_calls') is not None: - for tool_call in delta.get('tool_calls'): - if tool_call['id'] != '': - tool_id = tool_call['id'] - if tool_call['function']['name'] is not None: - tool_name = tool_call['function']['name'] - if tool_call['function']['arguments'] is None: - continue - tool_call['id'] = tool_id - tool_call['name'] = tool_name - for tc in tool_calls: - if tc['index'] == tool_call['index']: - tc['function']['arguments'] += tool_call['function']['arguments'] - break - else: - tool_calls.append(tool_call) - - if chunk.choices[0].finish_reason is not None: - break - message_delta['content'] = pending_content - message_delta['role'] = 'assistant' - - message_delta['tool_calls'] = tool_calls if tool_calls else None - return [message_delta] - - async def _make_msg( - self, - chat_completion: list[dict[str, typing.Any]], - ) -> provider_message.Message: - chatcmpl_message = chat_completion[0] - - # 确保 role 字段存在且不为 None - if 'role' not in chatcmpl_message or chatcmpl_message['role'] is None: - chatcmpl_message['role'] = 'assistant' - - message = provider_message.Message(**chatcmpl_message) - - return message - - async def _closure( - self, - query: pipeline_query.Query, - req_messages: list[dict], - use_model: requester.RuntimeLLMModel, - use_funcs: list[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> tuple[provider_message.Message, dict]: - self.client.api_key = use_model.provider.token_mgr.get_token() - - args = {} - args['model'] = use_model.model_entity.name - - if use_funcs: - tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs) - - if tools: - args['tools'] = tools - - # 设置此次请求中的messages - messages = req_messages.copy() - - # 检查vision - for msg in messages: - if 'content' in msg and isinstance(msg['content'], list): - for me in msg['content']: - if me['type'] == 'image_base64': - me['image_url'] = {'url': me['image_base64']} - me['type'] = 'image_url' - del me['image_base64'] - - args['messages'] = messages - - # 发送请求 - resp = await self._req(query, args, extra_body=extra_args, remove_think=remove_think) - - # 处理请求结果 - message = await self._make_msg(resp) - - # ModelScope uses streaming, usage info not available - usage_info = {} - - return message, usage_info - - async def _req_stream( - self, - args: dict, - extra_body: dict = {}, - ) -> chat_completion.ChatCompletion: - async for chunk in await self.client.chat.completions.create(**args, extra_body=extra_body): - yield chunk - - async def _closure_stream( - self, - query: pipeline_query.Query, - req_messages: list[dict], - use_model: requester.RuntimeLLMModel, - use_funcs: list[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]: - self.client.api_key = use_model.provider.token_mgr.get_token() - - args = {} - args['model'] = use_model.model_entity.name - - if use_funcs: - tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs) - - if tools: - args['tools'] = tools - - # 设置此次请求中的messages - messages = req_messages.copy() - - # 检查vision - for msg in messages: - if 'content' in msg and isinstance(msg['content'], list): - for me in msg['content']: - if me['type'] == 'image_base64': - me['image_url'] = {'url': me['image_base64']} - me['type'] = 'image_url' - del me['image_base64'] - - args['messages'] = messages - args['stream'] = True - - # 流式处理状态 - # tool_calls_map: dict[str, provider_message.ToolCall] = {} - chunk_idx = 0 - thinking_started = False - thinking_ended = False - role = 'assistant' # 默认角色 - # accumulated_reasoning = '' # 仅用于判断何时结束思维链 - - async for chunk in self._req_stream(args, extra_body=extra_args): - # 解析 chunk 数据 - if hasattr(chunk, 'choices') and chunk.choices: - choice = chunk.choices[0] - delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {} - finish_reason = getattr(choice, 'finish_reason', None) - else: - delta = {} - finish_reason = None - - # 从第一个 chunk 获取 role,后续使用这个 role - if 'role' in delta and delta['role']: - role = delta['role'] - - # 获取增量内容 - delta_content = delta.get('content', '') - reasoning_content = delta.get('reasoning_content', '') - - # 处理 reasoning_content - if reasoning_content: - # accumulated_reasoning += reasoning_content - # 如果设置了 remove_think,跳过 reasoning_content - if remove_think: - chunk_idx += 1 - continue - - # 第一次出现 reasoning_content,添加 开始标签 - if not thinking_started: - thinking_started = True - delta_content = '\n' + reasoning_content - else: - # 继续输出 reasoning_content - delta_content = reasoning_content - elif thinking_started and not thinking_ended and delta_content: - # reasoning_content 结束,normal content 开始,添加 结束标签 - thinking_ended = True - delta_content = '\n\n' + delta_content - - # 处理 content 中已有的 标签(如果需要移除) - # if delta_content and remove_think and '' in delta_content: - # import re - # - # # 移除 标签及其内容 - # delta_content = re.sub(r'.*?', '', delta_content, flags=re.DOTALL) - - # 处理工具调用增量 - if delta.get('tool_calls'): - for tool_call in delta['tool_calls']: - if tool_call['id'] != '': - tool_id = tool_call['id'] - if tool_call['function']['name'] is not None: - tool_name = tool_call['function']['name'] - - if tool_call['type'] is None: - tool_call['type'] = 'function' - tool_call['id'] = tool_id - tool_call['function']['name'] = tool_name - tool_call['function']['arguments'] = ( - '' if tool_call['function']['arguments'] is None else tool_call['function']['arguments'] - ) - - # 跳过空的第一个 chunk(只有 role 没有内容) - if chunk_idx == 0 and not delta_content and not reasoning_content and not delta.get('tool_calls'): - chunk_idx += 1 - continue - - # 构建 MessageChunk - 只包含增量内容 - chunk_data = { - 'role': role, - 'content': delta_content if delta_content else None, - 'tool_calls': delta.get('tool_calls'), - 'is_final': bool(finish_reason), - } - - # 移除 None 值 - chunk_data = {k: v for k, v in chunk_data.items() if v is not None} - - yield provider_message.MessageChunk(**chunk_data) - chunk_idx += 1 - # return - - async def invoke_llm( - self, - query: pipeline_query.Query, - model: entities.LLMModelInfo, - messages: typing.List[provider_message.Message], - funcs: typing.List[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> provider_message.Message: - req_messages = [] # req_messages 仅用于类内,外部同步由 query.messages 进行 - for m in messages: - msg_dict = m.dict(exclude_none=True) - content = msg_dict.get('content') - if isinstance(content, list): - # 检查 content 列表中是否每个部分都是文本 - if all(isinstance(part, dict) and part.get('type') == 'text' for part in content): - # 将所有文本部分合并为一个字符串 - msg_dict['content'] = '\n'.join(part['text'] for part in content) - req_messages.append(msg_dict) - - try: - return await self._closure( - query=query, - req_messages=req_messages, - use_model=model, - use_funcs=funcs, - extra_args=extra_args, - remove_think=remove_think, - ) - except asyncio.TimeoutError: - raise errors.RequesterError('请求超时') - except openai.BadRequestError as e: - if 'context_length_exceeded' in e.message: - raise errors.RequesterError(f'上文过长,请重置会话: {e.message}') - else: - raise errors.RequesterError(f'请求参数错误: {e.message}') - except openai.AuthenticationError as e: - raise errors.RequesterError(f'无效的 api-key: {e.message}') - except openai.NotFoundError as e: - raise errors.RequesterError(f'请求路径错误: {e.message}') - except openai.RateLimitError as e: - raise errors.RequesterError(f'请求过于频繁或余额不足: {e.message}') - except openai.APIError as e: - raise errors.RequesterError(f'请求错误: {e.message}') - - async def invoke_llm_stream( - self, - query: pipeline_query.Query, - model: requester.RuntimeLLMModel, - messages: typing.List[provider_message.Message], - funcs: typing.List[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> provider_message.MessageChunk: - req_messages = [] # req_messages 仅用于类内,外部同步由 query.messages 进行 - for m in messages: - msg_dict = m.dict(exclude_none=True) - content = msg_dict.get('content') - if isinstance(content, list): - # 检查 content 列表中是否每个部分都是文本 - if all(isinstance(part, dict) and part.get('type') == 'text' for part in content): - # 将所有文本部分合并为一个字符串 - msg_dict['content'] = '\n'.join(part['text'] for part in content) - req_messages.append(msg_dict) - - try: - async for item in self._closure_stream( - query=query, - req_messages=req_messages, - use_model=model, - use_funcs=funcs, - extra_args=extra_args, - remove_think=remove_think, - ): - yield item - - except asyncio.TimeoutError: - raise errors.RequesterError('请求超时') - except openai.BadRequestError as e: - if 'context_length_exceeded' in e.message: - raise errors.RequesterError(f'上文过长,请重置会话: {e.message}') - else: - raise errors.RequesterError(f'请求参数错误: {e.message}') - except openai.AuthenticationError as e: - raise errors.RequesterError(f'无效的 api-key: {e.message}') - except openai.NotFoundError as e: - raise errors.RequesterError(f'请求路径错误: {e.message}') - except openai.RateLimitError as e: - raise errors.RequesterError(f'请求过于频繁或余额不足: {e.message}') - except openai.APIError as e: - raise errors.RequesterError(f'请求错误: {e.message}') diff --git a/src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml index 8d22002db..cc9859e61 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: 魔搭社区 icon: modelscope.svg spec: + litellm_provider: openai config: - name: base_url label: @@ -29,8 +30,11 @@ spec: type: int required: true default: 120 + alias: "modelscope ModelScope 魔搭 魔塔 摩搭 阿里 modelscope-aigc qwen bge" support_type: - llm + - text-embedding + - rerank provider_category: maas execution: python: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.py deleted file mode 100644 index b6852963e..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -import typing - - -from . import chatcmpl -from .. import requester -import langbot_plugin.api.entities.builtin.resource.tool as resource_tool -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -class MoonshotChatCompletions(chatcmpl.OpenAIChatCompletions): - """Moonshot ChatCompletion API 请求器""" - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://api.moonshot.cn/v1', - 'timeout': 120, - } - - async def _closure( - self, - query: pipeline_query.Query, - req_messages: list[dict], - use_model: requester.RuntimeLLMModel, - use_funcs: list[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> tuple[provider_message.Message, dict]: - self.client.api_key = use_model.provider.token_mgr.get_token() - - args = {} - args['model'] = use_model.model_entity.name - - if use_funcs: - tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs) - - if tools: - args['tools'] = tools - - # 设置此次请求中的messages - messages = req_messages - - # deepseek 不支持多模态,把content都转换成纯文字 - for m in messages: - if 'content' in m and isinstance(m['content'], list): - m['content'] = ' '.join([c['text'] for c in m['content']]) - - # 删除空的,不知道干嘛的,直接删了。 - # messages = [m for m in messages if m["content"].strip() != "" and ('tool_calls' not in m or not m['tool_calls'])] - - args['messages'] = messages - - # 发送请求 - resp = await self._req(args, extra_body=extra_args) - - # 处理请求结果 - message = await self._make_msg(resp, remove_think) - - # Extract token usage from response - usage_info = {} - if hasattr(resp, 'usage') and resp.usage: - usage_info['input_tokens'] = resp.usage.prompt_tokens or 0 - usage_info['output_tokens'] = resp.usage.completion_tokens or 0 - usage_info['total_tokens'] = resp.usage.total_tokens or 0 - - return message, usage_info diff --git a/src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.yaml index 7a7e30603..cffc2f060 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.yaml @@ -3,10 +3,11 @@ kind: LLMAPIRequester metadata: name: moonshot-chat-completions label: - en_US: Moonshot - zh_Hans: 月之暗面 + en_US: Moonshot / Kimi (Global · api.moonshot.ai) + zh_Hans: 月之暗面 / Kimi(国际站 · api.moonshot.ai) icon: moonshot.png spec: + litellm_provider: openai config: - name: base_url label: @@ -22,6 +23,7 @@ spec: type: integer required: true default: 120 + alias: "moonshot Moonshot 月之暗面 月暗 kimi Kimi 月之 暗面 moonshot-v1 k2" support_type: - llm provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/moonshotcnchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/moonshotcnchatcmpl.yaml new file mode 100644 index 000000000..90316a1e8 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/moonshotcnchatcmpl.yaml @@ -0,0 +1,33 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: moonshot-cn-chat-completions + label: + en_US: Moonshot / Kimi (China · api.moonshot.cn) + zh_Hans: 月之暗面 / Kimi(国内站 · api.moonshot.cn) + icon: moonshot.png +spec: + litellm_provider: openai + config: + - name: base_url + label: + en_US: Base URL + zh_Hans: 基础 URL + type: string + required: true + default: https://api.moonshot.cn/v1 + - name: timeout + label: + en_US: Timeout + zh_Hans: 超时时间 + type: integer + required: true + default: 120 + alias: "moonshot Moonshot 月之暗面 月暗 kimi Kimi 月之 暗面 moonshot-v1 k2 cn 国内 国内站" + support_type: + - llm + provider_category: manufacturer +execution: + python: + path: ./moonshotchatcmpl.py + attr: MoonshotChatCompletions diff --git a/src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.py deleted file mode 100644 index 3c2bd3fb2..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -import typing -import openai - -from . import chatcmpl - - -class NewAPIChatCompletions(chatcmpl.OpenAIChatCompletions): - """New API ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'http://localhost:3000/v1', - 'timeout': 120, - } diff --git a/src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.yaml index e0f44e99b..694af440e 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: New API icon: newapi.png spec: + litellm_provider: openai config: - name: base_url label: @@ -22,9 +23,11 @@ spec: type: integer required: true default: 120 + alias: "newapi new-api New API one-api oneapi 中转 中转站 aggregator 聚合 网关 gateway rerank" support_type: - llm - text-embedding + - rerank provider_category: maas execution: python: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.py b/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.py deleted file mode 100644 index 50f601d7e..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.py +++ /dev/null @@ -1,314 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -import typing -from typing import Union, Mapping, Any, AsyncIterator -import uuid -import json - -import ollama -import httpx - -from .. import errors, requester -import langbot_plugin.api.entities.builtin.resource.tool as resource_tool -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message - -REQUESTER_NAME: str = 'ollama-chat' - - -class OllamaChatCompletions(requester.ProviderAPIRequester): - """Ollama平台 ChatCompletion API请求器""" - - client: ollama.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'http://127.0.0.1:11434', - 'timeout': 120, - } - - async def initialize(self): - os.environ['OLLAMA_HOST'] = self.requester_cfg['base_url'] - self.client = ollama.AsyncClient(timeout=self.requester_cfg['timeout']) - - def _infer_model_type(self, model_id: str) -> str: - normalized_model_id = (model_id or '').lower() - embedding_keywords = ('embedding', 'embed', 'bge-', 'e5-', 'm3e', 'gte-', 'text-embedding') - return 'embedding' if any(keyword in normalized_model_id for keyword in embedding_keywords) else 'llm' - - def _infer_model_abilities(self, item: dict[str, typing.Any], model_id: str) -> list[str]: - normalized_model_id = (model_id or '').lower() - abilities: set[str] = set() - details = item.get('details', {}) or {} - families = details.get('families', []) or [] - tokens = [normalized_model_id, str(details.get('family', '')).lower()] - tokens.extend(str(family).lower() for family in families) - - if any(keyword in token for token in tokens for keyword in ('vision', 'vl', 'omni', 'llava', 'ocr')): - abilities.add('vision') - if any(keyword in token for token in tokens for keyword in ('tool', 'function')): - abilities.add('func_call') - return sorted(abilities) - - async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]: - del api_key - models_url = f'{self.requester_cfg["base_url"].rstrip("/")}/api/tags' - - async with httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']) as client: - response = await client.get(models_url) - response.raise_for_status() - payload = response.json() - - models: list[dict[str, typing.Any]] = [] - for item in payload.get('models', []): - model_id = item.get('model') or item.get('name') - if not model_id: - continue - models.append( - { - 'id': model_id, - 'name': item.get('name', model_id), - 'type': self._infer_model_type(model_id), - 'abilities': self._infer_model_abilities(item, model_id), - } - ) - - models.sort(key=lambda item: (item['type'] != 'llm', item['name'].lower())) - return { - 'models': models, - 'debug': { - 'request': { - 'method': 'GET', - 'url': models_url, - }, - 'response': payload, - }, - } - - async def _req( - self, - args: dict, - ) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]: - return await self.client.chat(**args) - - async def _closure( - self, - query: pipeline_query.Query, - req_messages: list[dict], - use_model: requester.RuntimeLLMModel, - use_funcs: list[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> provider_message.Message: - args = extra_args.copy() - args['model'] = use_model.model_entity.name - - messages: list[dict] = req_messages.copy() - for msg in messages: - if 'content' in msg and isinstance(msg['content'], list): - text_content: list = [] - image_urls: list = [] - for me in msg['content']: - if me['type'] == 'text': - text_content.append(me['text']) - elif me['type'] == 'image_base64': - image_urls.append(me['image_base64']) - - msg['content'] = '\n'.join(text_content) - msg['images'] = [url.split(',')[1] for url in image_urls] - if 'tool_calls' in msg: # LangBot 内部以 str 存储 tool_calls 的参数,这里需要转换为 dict - for tool_call in msg['tool_calls']: - tool_call['function']['arguments'] = json.loads(tool_call['function']['arguments']) - args['messages'] = messages - - args['tools'] = [] - if use_funcs: - tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs) - if tools: - args['tools'] = tools - - resp = await self._req(args) - message: provider_message.Message = await self._make_msg(resp) - return message - - async def _make_msg(self, chat_completions: ollama.ChatResponse) -> provider_message.Message: - message: ollama.Message = chat_completions.message - if message is None: - raise ValueError("chat_completions must contain a 'message' field") - - ret_msg: provider_message.Message = None - - if message.content is not None: - ret_msg = provider_message.Message(role='assistant', content=message.content) - if message.tool_calls is not None and len(message.tool_calls) > 0: - tool_calls: list[provider_message.ToolCall] = [] - - for tool_call in message.tool_calls: - tool_calls.append( - provider_message.ToolCall( - id=uuid.uuid4().hex, - type='function', - function=provider_message.FunctionCall( - name=tool_call.function.name, - arguments=json.dumps(tool_call.function.arguments), - ), - ) - ) - ret_msg.tool_calls = tool_calls - - return ret_msg - - async def _prepare_messages( - self, - messages: typing.List[provider_message.Message], - ) -> list[dict]: - """Prepare messages for Ollama API request.""" - req_messages: list = [] - for m in messages: - msg_dict: dict = m.dict(exclude_none=True) - content: Any = msg_dict.get('content') - if isinstance(content, list): - if all(isinstance(part, dict) and part.get('type') == 'text' for part in content): - msg_dict['content'] = '\n'.join(part['text'] for part in content) - req_messages.append(msg_dict) - return req_messages - - async def invoke_llm( - self, - query: pipeline_query.Query, - model: requester.RuntimeLLMModel, - messages: typing.List[provider_message.Message], - funcs: typing.List[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> provider_message.Message: - req_messages = await self._prepare_messages(messages) - try: - return await self._closure( - query=query, - req_messages=req_messages, - use_model=model, - use_funcs=funcs, - extra_args=extra_args, - remove_think=remove_think, - ) - except asyncio.TimeoutError: - raise errors.RequesterError('请求超时') - - async def invoke_llm_stream( - self, - query: pipeline_query.Query, - model: requester.RuntimeLLMModel, - messages: typing.List[provider_message.Message], - funcs: typing.List[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> provider_message.MessageChunk: - req_messages = await self._prepare_messages(messages) - - try: - args = extra_args.copy() - args['model'] = model.model_entity.name - - # Process messages for Ollama format - msgs: list[dict] = req_messages.copy() - for msg in msgs: - if 'content' in msg and isinstance(msg['content'], list): - text_content: list = [] - image_urls: list = [] - for me in msg['content']: - if me['type'] == 'text': - text_content.append(me['text']) - elif me['type'] == 'image_base64': - image_urls.append(me['image_base64']) - msg['content'] = '\n'.join(text_content) - msg['images'] = [url.split(',')[1] for url in image_urls] - if 'tool_calls' in msg: - for tool_call in msg['tool_calls']: - tool_call['function']['arguments'] = json.loads(tool_call['function']['arguments']) - args['messages'] = msgs - - args['tools'] = [] - if funcs: - tools = await self.ap.tool_mgr.generate_tools_for_openai(funcs) - if tools: - args['tools'] = tools - - args['stream'] = True - - chunk_idx = 0 - thinking_started = False - thinking_ended = False - role = 'assistant' - - async for chunk in await self.client.chat(**args): - message: ollama.Message = chunk.message - done = chunk.done - - delta_content = message.content or '' - reasoning_content = getattr(message, 'thinking', '') or '' - - # Handle reasoning/thinking content - if reasoning_content: - if remove_think: - chunk_idx += 1 - continue - - if not thinking_started: - thinking_started = True - delta_content = '\n' + reasoning_content - else: - delta_content = reasoning_content - elif thinking_started and not thinking_ended and delta_content: - thinking_ended = True - delta_content = '\n\n' + delta_content - - # Handle tool calls - tool_calls_data = None - if message.tool_calls: - tool_calls_data = [] - for tc in message.tool_calls: - tool_calls_data.append( - { - 'id': uuid.uuid4().hex, - 'type': 'function', - 'function': { - 'name': tc.function.name, - 'arguments': json.dumps(tc.function.arguments), - }, - } - ) - - # Skip empty first chunk - if chunk_idx == 0 and not delta_content and not reasoning_content and not tool_calls_data: - chunk_idx += 1 - continue - - chunk_data = { - 'role': role, - 'content': delta_content if delta_content else None, - 'tool_calls': tool_calls_data, - 'is_final': bool(done), - } - chunk_data = {k: v for k, v in chunk_data.items() if v is not None} - - yield provider_message.MessageChunk(**chunk_data) - chunk_idx += 1 - - except asyncio.TimeoutError: - raise errors.RequesterError('请求超时') - - async def invoke_embedding( - self, - model: requester.RuntimeEmbeddingModel, - input_text: list[str], - extra_args: dict[str, typing.Any] = {}, - ) -> list[list[float]]: - return ( - await self.client.embed( - model=model.model_entity.name, - input=input_text, - **extra_args, - ) - ).embeddings diff --git a/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.yaml b/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.yaml index a724f8f8f..83e116c8f 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: Ollama icon: ollama.svg spec: + litellm_provider: ollama config: - name: base_url label: @@ -22,6 +23,7 @@ spec: type: integer required: true default: 120 + alias: "ollama Ollama 本地 local 本地部署 self-hosted llama gguf 私有化" support_type: - llm - text-embedding diff --git a/src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.py deleted file mode 100644 index 17b884312..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -import typing -import openai - -from . import modelscopechatcmpl - - -class OpenRouterChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions): - """OpenRouter ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://openrouter.ai/api/v1', - 'timeout': 120, - } - - async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]: - original_base_url = self.requester_cfg.get('base_url', '') - self.requester_cfg['base_url'] = 'https://openrouter.ai/api/v1' - try: - return await super().scan_models(api_key) - finally: - self.requester_cfg['base_url'] = original_base_url diff --git a/src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml index 71064dc0d..9fe351ce0 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: OpenRouter icon: openrouter.svg spec: + litellm_provider: openai config: - name: base_url label: @@ -22,6 +23,7 @@ spec: type: integer required: true default: 120 + alias: "openrouter OpenRouter open-router 中转 中转站 路由 aggregator gpt claude gemini llama" support_type: - llm - text-embedding diff --git a/src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.py deleted file mode 100644 index 1836bd627..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.py +++ /dev/null @@ -1,208 +0,0 @@ -from __future__ import annotations - -import openai -import typing - -from . import chatcmpl -from .. import requester -import openai.types.chat.chat_completion as chat_completion -import re -import langbot_plugin.api.entities.builtin.provider.message as provider_message -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.resource.tool as resource_tool - - -class PPIOChatCompletions(chatcmpl.OpenAIChatCompletions): - """欧派云 ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://api.ppinfra.com/v3/openai', - 'timeout': 120, - } - - is_think: bool = False - - async def _make_msg( - self, - chat_completion: chat_completion.ChatCompletion, - remove_think: bool, - ) -> provider_message.Message: - chatcmpl_message = chat_completion.choices[0].message.model_dump() - # print(chatcmpl_message.keys(), chatcmpl_message.values()) - - # 确保 role 字段存在且不为 None - if 'role' not in chatcmpl_message or chatcmpl_message['role'] is None: - chatcmpl_message['role'] = 'assistant' - - reasoning_content = chatcmpl_message['reasoning_content'] if 'reasoning_content' in chatcmpl_message else None - - # deepseek的reasoner模型 - chatcmpl_message['content'] = await self._process_thinking_content( - chatcmpl_message['content'], reasoning_content, remove_think - ) - - # 移除 reasoning_content 字段,避免传递给 Message - if 'reasoning_content' in chatcmpl_message: - del chatcmpl_message['reasoning_content'] - - message = provider_message.Message(**chatcmpl_message) - - return message - - async def _process_thinking_content( - self, - content: str, - reasoning_content: str = None, - remove_think: bool = False, - ) -> tuple[str, str]: - """处理思维链内容 - - Args: - content: 原始内容 - reasoning_content: reasoning_content 字段内容 - remove_think: 是否移除思维链 - - Returns: - 处理后的内容 - """ - if remove_think: - content = re.sub(r'.*?', '', content, flags=re.DOTALL) - else: - if reasoning_content is not None: - content = '\n' + reasoning_content + '\n\n' + content - return content - - async def _make_msg_chunk( - self, - delta: dict[str, typing.Any], - idx: int, - ) -> provider_message.MessageChunk: - # 处理流式chunk和完整响应的差异 - # print(chat_completion.choices[0]) - - # 确保 role 字段存在且不为 None - if 'role' not in delta or delta['role'] is None: - delta['role'] = 'assistant' - - reasoning_content = delta['reasoning_content'] if 'reasoning_content' in delta else None - - delta['content'] = '' if delta['content'] is None else delta['content'] - # print(reasoning_content) - - # deepseek的reasoner模型 - - if reasoning_content is not None: - delta['content'] += reasoning_content - - message = provider_message.MessageChunk(**delta) - - return message - - async def _closure_stream( - self, - query: pipeline_query.Query, - req_messages: list[dict], - use_model: requester.RuntimeLLMModel, - use_funcs: list[resource_tool.LLMTool] = None, - extra_args: dict[str, typing.Any] = {}, - remove_think: bool = False, - ) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]: - self.client.api_key = use_model.provider.token_mgr.get_token() - - args = {} - args['model'] = use_model.model_entity.name - - if use_funcs: - tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs) - - if tools: - args['tools'] = tools - - # 设置此次请求中的messages - messages = req_messages.copy() - - # 检查vision - for msg in messages: - if 'content' in msg and isinstance(msg['content'], list): - for me in msg['content']: - if me['type'] == 'image_base64': - me['image_url'] = {'url': me['image_base64']} - me['type'] = 'image_url' - del me['image_base64'] - - args['messages'] = messages - args['stream'] = True - - # tool_calls_map: dict[str, provider_message.ToolCall] = {} - chunk_idx = 0 - thinking_started = False - thinking_ended = False - role = 'assistant' # 默认角色 - async for chunk in self._req_stream(args, extra_body=extra_args): - # 解析 chunk 数据 - if hasattr(chunk, 'choices') and chunk.choices: - choice = chunk.choices[0] - delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {} - finish_reason = getattr(choice, 'finish_reason', None) - else: - delta = {} - finish_reason = None - - # 从第一个 chunk 获取 role,后续使用这个 role - if 'role' in delta and delta['role']: - role = delta['role'] - - # 获取增量内容 - delta_content = delta.get('content', '') - # reasoning_content = delta.get('reasoning_content', '') - - if remove_think: - if delta['content'] is not None: - if '' in delta['content'] and not thinking_started and not thinking_ended: - thinking_started = True - continue - elif delta['content'] == r'' and not thinking_ended: - thinking_ended = True - continue - elif thinking_ended and delta['content'] == '\n\n' and thinking_started: - thinking_started = False - continue - elif thinking_started and not thinking_ended: - continue - - # delta_tool_calls = None - if delta.get('tool_calls'): - for tool_call in delta['tool_calls']: - if tool_call['id'] and tool_call['function']['name']: - tool_id = tool_call['id'] - tool_name = tool_call['function']['name'] - - if tool_call['id'] is None: - tool_call['id'] = tool_id - if tool_call['function']['name'] is None: - tool_call['function']['name'] = tool_name - if tool_call['function']['arguments'] is None: - tool_call['function']['arguments'] = '' - if tool_call['type'] is None: - tool_call['type'] = 'function' - - # 跳过空的第一个 chunk(只有 role 没有内容) - if chunk_idx == 0 and not delta_content and not delta.get('tool_calls'): - chunk_idx += 1 - continue - - # 构建 MessageChunk - 只包含增量内容 - chunk_data = { - 'role': role, - 'content': delta_content if delta_content else None, - 'tool_calls': delta.get('tool_calls'), - 'is_final': bool(finish_reason), - } - - # 移除 None 值 - chunk_data = {k: v for k, v in chunk_data.items() if v is not None} - - yield provider_message.MessageChunk(**chunk_data) - chunk_idx += 1 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml index 9e8eb1b01..79408fb5a 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: 派欧云 icon: ppio.svg spec: + litellm_provider: openai config: - name: base_url label: @@ -29,9 +30,11 @@ spec: type: int required: true default: 120 + alias: "ppio PPIO 派欧 派欧云 paiou ppinfra 派欧算力 bge embedding rerank" support_type: - llm - text-embedding + - rerank provider_category: maas execution: python: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.py deleted file mode 100644 index a68b6896a..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -import openai -import typing - -from . import chatcmpl - - -class QHAIGCChatCompletions(chatcmpl.OpenAIChatCompletions): - """启航 AI ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://api.qhaigc.com/v1', - 'timeout': 120, - } diff --git a/src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.yaml index 46ae1fad1..28680c0f6 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: 启航 AI icon: qhaigc.png spec: + litellm_provider: openai config: - name: base_url label: @@ -29,9 +30,11 @@ spec: type: int required: true default: 120 + alias: "qhaigc 青华 qinghua aigc 中转 中转站" support_type: - llm - text-embedding + - rerank provider_category: maas execution: python: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.py index 0c7a940fd..84c59b74b 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.py @@ -2,19 +2,16 @@ from __future__ import annotations import typing -import openai - -from . import chatcmpl +from . import litellmchat -class QiniuChatCompletions(chatcmpl.OpenAIChatCompletions): +class QiniuChatCompletions(litellmchat.LiteLLMRequester): """七牛云 ChatCompletion API 请求器""" - client: openai.AsyncClient - default_config: dict[str, typing.Any] = { 'base_url': 'https://api.qnaigc.com/v1', 'timeout': 120, + 'custom_llm_provider': 'openai', } async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.yaml index 5655d7434..96a048fa5 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.yaml @@ -22,8 +22,11 @@ spec: type: integer required: true default: 120 + alias: "qiniu 七牛 七牛云 qiniu-cloud kodo ai推理 bge embedding rerank" support_type: - llm + - text-embedding + - rerank provider_category: maas execution: python: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/seekdbembed.yaml b/src/langbot/pkg/provider/modelmgr/requesters/seekdbembed.yaml index 1ff48b50b..d9aedad3b 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/seekdbembed.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/seekdbembed.yaml @@ -12,6 +12,7 @@ metadata: icon: seekdb.svg spec: config: [] + alias: "seekdb SeekDB seek-db 向量 vector embedding 嵌入 数据库" support_type: - text-embedding provider_category: builtin diff --git a/src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.py b/src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.py deleted file mode 100644 index 122eaf7d1..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -import openai -import typing - -from . import chatcmpl -import openai.types.chat.chat_completion as chat_completion - - -class ShengSuanYunChatCompletions(chatcmpl.OpenAIChatCompletions): - """胜算云(ModelSpot.AI) ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://router.shengsuanyun.com/api/v1', - 'timeout': 120, - } - - async def _req( - self, - args: dict, - extra_body: dict = {}, - ) -> chat_completion.ChatCompletion: - return await self.client.chat.completions.create( - **args, - extra_body=extra_body, - extra_headers={ - 'HTTP-Referer': 'https://langbot.app', - 'X-Title': 'LangBot', - }, - ) diff --git a/src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.yaml b/src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.yaml index 77cf682c8..ae54668dc 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: 胜算云 icon: shengsuanyun.svg spec: + litellm_provider: openai config: - name: base_url label: @@ -29,9 +30,11 @@ spec: type: int required: true default: 120 + alias: "shengsuanyun 胜算云 胜算 sheng suan yun 算力 中转" support_type: - llm - text-embedding + - rerank provider_category: maas execution: python: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.py deleted file mode 100644 index 3636d9d19..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -import typing -import openai - -from . import chatcmpl - - -class SiliconFlowChatCompletions(chatcmpl.OpenAIChatCompletions): - """SiliconFlow ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://api.siliconflow.cn/v1', - 'timeout': 120, - } diff --git a/src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.yaml index 11a2ffa33..b4e5d736e 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: 硅基流动 icon: siliconflow.svg spec: + litellm_provider: openai config: - name: base_url label: @@ -22,6 +23,7 @@ spec: type: integer required: true default: 120 + alias: "siliconflow SiliconFlow 硅基流动 硅基 silicon flow guiji bge BAAI embedding rerank qwen deepseek" support_type: - llm - text-embedding diff --git a/src/langbot/pkg/provider/modelmgr/requesters/spacechatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/spacechatcmpl.py deleted file mode 100644 index 91740a1f3..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/spacechatcmpl.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -import typing -import openai - -from . import chatcmpl - - -class LangBotSpaceChatCompletions(chatcmpl.OpenAIChatCompletions): - """LangBot Space ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://api.langbot.cloud/v1', - 'timeout': 120, - } diff --git a/src/langbot/pkg/provider/modelmgr/requesters/spacechatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/spacechatcmpl.yaml index 29c23a834..4bfdb98a6 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/spacechatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/spacechatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: Space icon: space.webp spec: + litellm_provider: openai config: - name: base_url label: @@ -22,9 +23,11 @@ spec: type: integer required: true default: 120 + alias: "space LangBot Space langbot-space 官方 official 自有 内置 rerank embedding" support_type: - llm - text-embedding + - rerank provider_category: maas execution: python: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/tencent.svg b/src/langbot/pkg/provider/modelmgr/requesters/tencent.svg new file mode 100644 index 000000000..de32c1bfb --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/tencent.svg @@ -0,0 +1,5 @@ + + + Tencent + Hunyuan + diff --git a/src/langbot/pkg/provider/modelmgr/requesters/tencentchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/tencentchatcmpl.yaml new file mode 100644 index 000000000..4e2d68bdd --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/tencentchatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: tencent-chat-completions + label: + en_US: Tencent Hunyuan + zh_Hans: 腾讯混元 + icon: tencent.svg +spec: + litellm_provider: openai + config: + - name: base_url + label: + en_US: Base URL + zh_Hans: 基础 URL + type: string + required: true + default: https://hunyuan.tencentcloudapi.com/v1 + - name: timeout + label: + en_US: Timeout + zh_Hans: 超时时间 + type: integer + required: true + default: 120 + alias: "tencent 腾讯 腾讯云 hunyuan 混元 tencent-cloud txcloud 元宝" + support_type: + - llm + - text-embedding + - rerank + provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/together.svg b/src/langbot/pkg/provider/modelmgr/requesters/together.svg new file mode 100644 index 000000000..b6ce0f801 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/together.svg @@ -0,0 +1,5 @@ + + + Together + AI + diff --git a/src/langbot/pkg/provider/modelmgr/requesters/togetherchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/togetherchatcmpl.yaml new file mode 100644 index 000000000..c2869a248 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/togetherchatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: together-chat-completions + label: + en_US: Together AI + zh_Hans: Together AI + icon: together.svg +spec: + litellm_provider: together_ai + config: + - name: base_url + label: + en_US: Base URL + zh_Hans: 基础 URL + type: string + required: true + default: https://api.together.xyz/v1 + - name: timeout + label: + en_US: Timeout + zh_Hans: 超时时间 + type: integer + required: true + default: 120 + alias: "together Together together-ai togetherai 中转 llama qwen bge rerank embedding" + support_type: + - llm + - text-embedding + - rerank + provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/tokenpony.yaml b/src/langbot/pkg/provider/modelmgr/requesters/tokenpony.yaml index f160bdea8..c8fba393c 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/tokenpony.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/tokenpony.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: 小马算力 icon: tokenpony.svg spec: + litellm_provider: openai config: - name: base_url label: @@ -22,9 +23,11 @@ spec: type: integer required: true default: 120 + alias: "tokenpony TokenPony token-pony 小马 token 小马算力 中转" support_type: - llm - text-embedding + - rerank provider_category: maas execution: python: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/tokenponychatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/tokenponychatcmpl.py deleted file mode 100644 index 923114541..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/tokenponychatcmpl.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -import typing -import openai - -from . import chatcmpl - - -class TokenPonyChatCompletions(chatcmpl.OpenAIChatCompletions): - """TokenPony ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://api.tokenpony.cn/v1', - 'timeout': 120, - } diff --git a/src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.py deleted file mode 100644 index 7eb68956d..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -import typing -import openai - -from . import chatcmpl - - -class VolcArkChatCompletions(chatcmpl.OpenAIChatCompletions): - """火山方舟大模型平台 ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://ark.cn-beijing.volces.com/api/v3', - 'timeout': 120, - } diff --git a/src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.yaml index e5c82657c..fd709f0e0 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: 火山方舟 icon: volcark.svg spec: + litellm_provider: openai config: - name: base_url label: @@ -22,8 +23,11 @@ spec: type: integer required: true default: 120 + alias: "volcark volcengine 火山 火山方舟 火山引擎 ark 方舟 字节 bytedance doubao 豆包 seed embedding rerank" support_type: - llm + - text-embedding + - rerank provider_category: maas execution: python: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/voyageairerank.yaml b/src/langbot/pkg/provider/modelmgr/requesters/voyageairerank.yaml index a47b8d473..c10b7e03c 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/voyageairerank.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/voyageairerank.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: Voyage AI icon: voyageai.svg spec: + litellm_provider: openai config: - name: base_url label: @@ -22,6 +23,7 @@ spec: type: integer required: true default: 120 + alias: "voyage voyageai voyage-ai VoyageAI rerank 重排 reranker voyage-rerank embedding" support_type: - rerank provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.py deleted file mode 100644 index db2022f1c..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -import typing -import openai - -from . import chatcmpl - - -class XaiChatCompletions(chatcmpl.OpenAIChatCompletions): - """xAI ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://api.x.ai/v1', - 'timeout': 120, - } diff --git a/src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.yaml index 2e721d705..0d55f7ba1 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: xAI icon: xai.svg spec: + litellm_provider: openai config: - name: base_url label: @@ -22,6 +23,7 @@ spec: type: integer required: true default: 120 + alias: "xai xAI x-ai grok Grok 马斯克 musk x.ai 格罗克" support_type: - llm provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/yi.svg b/src/langbot/pkg/provider/modelmgr/requesters/yi.svg new file mode 100644 index 000000000..8dc5e8277 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/yi.svg @@ -0,0 +1,5 @@ + + + 01.AI + Yi + diff --git a/src/langbot/pkg/provider/modelmgr/requesters/yichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/yichatcmpl.yaml new file mode 100644 index 000000000..e75d0cdf0 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/yichatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: yi-chat-completions + label: + en_US: 01.AI Yi + zh_Hans: 零一万物 + icon: yi.svg +spec: + litellm_provider: openai + config: + - name: base_url + label: + en_US: Base URL + zh_Hans: 基础 URL + type: string + required: true + default: https://api.lingyiwanwu.com/v1 + - name: timeout + label: + en_US: Timeout + zh_Hans: 超时时间 + type: integer + required: true + default: 120 + alias: "yi 零一 零一万物 零一万 lingyiwanwu 01 01.ai 万智 yi-large yi-lightning embedding" + support_type: + - llm + - text-embedding + - rerank + provider_category: manufacturer diff --git a/src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.py deleted file mode 100644 index a1a070685..000000000 --- a/src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -import typing -import openai - -from . import chatcmpl - - -class ZhipuAIChatCompletions(chatcmpl.OpenAIChatCompletions): - """智谱AI ChatCompletion API 请求器""" - - client: openai.AsyncClient - - default_config: dict[str, typing.Any] = { - 'base_url': 'https://open.bigmodel.cn/api/paas/v4', - 'timeout': 120, - } diff --git a/src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.yaml index a4ebb2ec3..a0dabf8de 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.yaml @@ -7,6 +7,7 @@ metadata: zh_Hans: 智谱 AI icon: zhipuai.svg spec: + litellm_provider: openai config: - name: base_url label: @@ -22,8 +23,11 @@ spec: type: integer required: true default: 120 + alias: "zhipu zhipuai 智谱 智谱AI 智谱清言 glm GLM chatglm 清言 bigmodel embedding-3 rerank" support_type: - llm + - text-embedding + - rerank provider_category: manufacturer execution: python: diff --git a/src/langbot/pkg/provider/runner.py b/src/langbot/pkg/provider/runner.py index f89c079df..987b3a0e9 100644 --- a/src/langbot/pkg/provider/runner.py +++ b/src/langbot/pkg/provider/runner.py @@ -2,8 +2,12 @@ from __future__ import annotations import abc import typing +from typing import TYPE_CHECKING -from ..core import app +if TYPE_CHECKING: + from ..core import app + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + import langbot_plugin.api.entities.builtin.provider.message as provider_message preregistered_runners: list[typing.Type[RequestRunner]] = [] @@ -35,7 +39,7 @@ class RequestRunner(abc.ABC): @abc.abstractmethod async def run( - self, query: core_entities.Query - ) -> typing.AsyncGenerator[llm_entities.Message | llm_entities.MessageChunk, None]: + self, query: pipeline_query.Query + ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: """运行请求""" pass diff --git a/src/langbot/pkg/provider/runners/deerflowapi.py b/src/langbot/pkg/provider/runners/deerflowapi.py new file mode 100644 index 000000000..79c77126e --- /dev/null +++ b/src/langbot/pkg/provider/runners/deerflowapi.py @@ -0,0 +1,511 @@ +"""DeerFlow LangGraph API Runner + +参考 astrbot 的 deerflow_agent_runner 实现,适配 LangBot 的 Runner 接口。 + +特点: +- 使用 LangGraph HTTP API 接入 deer-flow 后端 +- 自动管理 thread_id(按 session 隔离) +- 支持 SSE 流式响应解析 +- 支持 streaming/非流式两种输出 +- 处理 values / messages-tuple / custom 三种事件 +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import typing +from collections import deque +from dataclasses import dataclass, field + + +from langbot.pkg.provider import runner +from langbot.pkg.core import app +import langbot_plugin.api.entities.builtin.provider.message as provider_message +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +from langbot.libs.deerflow_api import client, errors, stream_utils + + +_MAX_VALUES_HISTORY = 200 + + +@dataclass +class _StreamState: + """流式状态跟踪""" + + latest_text: str = '' + prev_text_for_streaming: str = '' + clarification_text: str = '' + task_failures: list[str] = field(default_factory=list) + seen_message_ids: set[str] = field(default_factory=set) + seen_message_order: deque[str] = field(default_factory=deque) + no_id_message_fingerprints: dict[int, str] = field(default_factory=dict) + baseline_initialized: bool = False + has_values_text: bool = False + run_values_messages: list[dict[str, typing.Any]] = field(default_factory=list) + timed_out: bool = False + + +@runner.runner_class('deerflow-api') +class DeerFlowAPIRunner(runner.RequestRunner): + """DeerFlow LangGraph API 对话请求器""" + + deerflow_client: client.AsyncDeerFlowClient + + def __init__(self, ap: app.Application, pipeline_config: dict): + super().__init__(ap, pipeline_config) + + cfg = self.pipeline_config['ai']['deerflow-api'] + + api_base = cfg.get('api-base', '').strip() + if not api_base or not api_base.startswith(('http://', 'https://')): + raise errors.DeerFlowAPIError( + message='DeerFlow API Base URL 格式错误,必须以 http:// 或 https:// 开头', + ) + + self.api_base = api_base + self.api_key = cfg.get('api-key', '') + self.auth_header = cfg.get('auth-header', '') + self.assistant_id = cfg.get('assistant-id', 'lead_agent') + self.model_name = cfg.get('model-name', '') + self.thinking_enabled = bool(cfg.get('thinking-enabled', False)) + self.plan_mode = bool(cfg.get('plan-mode', False)) + self.subagent_enabled = bool(cfg.get('subagent-enabled', False)) + self.max_concurrent_subagents = int(cfg.get('max-concurrent-subagents', 3)) + self.timeout = int(cfg.get('timeout', 300)) + self.recursion_limit = int(cfg.get('recursion-limit', 1000)) + + self.deerflow_client = client.AsyncDeerFlowClient( + api_base=self.api_base, + api_key=self.api_key, + auth_header=self.auth_header, + ) + + # ------------------------------------------------------------------ + # 辅助方法 + # ------------------------------------------------------------------ + + def _fingerprint_message(self, message: dict[str, typing.Any]) -> str: + try: + raw = json.dumps(message, sort_keys=True, ensure_ascii=False, default=str) + except (TypeError, ValueError): + raw = repr(message) + return hashlib.sha1(raw.encode('utf-8', errors='ignore')).hexdigest() + + def _remember_seen_message_id(self, state: _StreamState, msg_id: str) -> None: + if not msg_id or msg_id in state.seen_message_ids: + return + state.seen_message_ids.add(msg_id) + state.seen_message_order.append(msg_id) + while len(state.seen_message_order) > _MAX_VALUES_HISTORY: + dropped = state.seen_message_order.popleft() + state.seen_message_ids.discard(dropped) + + def _extract_new_messages_from_values( + self, + values_messages: list[typing.Any], + state: _StreamState, + ) -> list[dict[str, typing.Any]]: + new_messages: list[dict[str, typing.Any]] = [] + no_id_indexes_seen: set[int] = set() + for idx, msg in enumerate(values_messages): + if not isinstance(msg, dict): + continue + msg_id = stream_utils.get_message_id(msg) + if msg_id: + if msg_id in state.seen_message_ids: + continue + self._remember_seen_message_id(state, msg_id) + new_messages.append(msg) + continue + + no_id_indexes_seen.add(idx) + fp = self._fingerprint_message(msg) + if state.no_id_message_fingerprints.get(idx) == fp: + continue + state.no_id_message_fingerprints[idx] = fp + new_messages.append(msg) + + for idx in list(state.no_id_message_fingerprints.keys()): + if idx not in no_id_indexes_seen: + state.no_id_message_fingerprints.pop(idx, None) + return new_messages + + # ------------------------------------------------------------------ + # 用户输入处理 + # ------------------------------------------------------------------ + + def _build_user_content( + self, + prompt: str, + image_urls: list[str], + ) -> typing.Any: + """构建 LangGraph 兼容的 user content(支持多模态)""" + if not image_urls: + return prompt + + content: list[dict[str, typing.Any]] = [] + if prompt: + content.append({'type': 'text', 'text': prompt}) + for url in image_urls: + if not isinstance(url, str): + continue + url = url.strip() + if not url: + continue + if url.startswith(('http://', 'https://', 'data:')): + content.append({'type': 'image_url', 'image_url': {'url': url}}) + return content if content else prompt + + def _preprocess_user_message( + self, + query: pipeline_query.Query, + ) -> tuple[str, list[str]]: + """提取用户消息的纯文本与图片 URL 列表""" + plain_text = '' + image_urls: list[str] = [] + + if isinstance(query.user_message.content, str): + plain_text = query.user_message.content + elif isinstance(query.user_message.content, list): + for ce in query.user_message.content: + if ce.type == 'text': + plain_text += ce.text + elif ce.type == 'image_base64': + # 转换为 data URI 形式 + b64 = getattr(ce, 'image_base64', '') + if b64: + if not b64.startswith('data:'): + b64 = f'data:image/png;base64,{b64}' + image_urls.append(b64) + elif ce.type == 'image_url': + url = getattr(ce, 'image_url', '') + if url: + image_urls.append(url) + + return plain_text, image_urls + + # ------------------------------------------------------------------ + # 请求构造 + # ------------------------------------------------------------------ + + def _build_messages( + self, + prompt: str, + image_urls: list[str], + system_prompt: str = '', + ) -> list[dict[str, typing.Any]]: + messages: list[dict[str, typing.Any]] = [] + if system_prompt: + messages.append({'role': 'system', 'content': system_prompt}) + messages.append( + { + 'role': 'user', + 'content': self._build_user_content(prompt, image_urls), + } + ) + return messages + + def _build_runtime_configurable(self, thread_id: str) -> dict[str, typing.Any]: + cfg: dict[str, typing.Any] = { + 'thread_id': thread_id, + 'thinking_enabled': self.thinking_enabled, + 'is_plan_mode': self.plan_mode, + 'subagent_enabled': self.subagent_enabled, + } + if self.subagent_enabled: + cfg['max_concurrent_subagents'] = self.max_concurrent_subagents + if self.model_name: + cfg['model_name'] = self.model_name + return cfg + + def _build_payload( + self, + thread_id: str, + prompt: str, + image_urls: list[str], + system_prompt: str = '', + ) -> dict[str, typing.Any]: + runtime_configurable = self._build_runtime_configurable(thread_id) + return { + 'assistant_id': self.assistant_id, + 'input': { + 'messages': self._build_messages(prompt, image_urls, system_prompt), + }, + 'stream_mode': ['values', 'messages-tuple', 'custom'], + # DeerFlow 2.0 从 config.configurable 读取运行时覆盖 + # 同时保留 context 字段做向后兼容 + 'context': dict(runtime_configurable), + 'config': { + 'recursion_limit': self.recursion_limit, + 'configurable': runtime_configurable, + }, + } + + # ------------------------------------------------------------------ + # Session/Thread 管理 + # ------------------------------------------------------------------ + + async def _ensure_thread_id(self, query: pipeline_query.Query) -> str: + """从 query.session 取/创建 deerflow thread_id + + LangBot 使用 `query.session.using_conversation.uuid` 持久化 conversation id, + 我们复用这个字段存储 deerflow thread_id(与 Dify Runner 同样做法)。 + """ + thread_id = query.session.using_conversation.uuid or '' + if thread_id: + return thread_id + + thread = await self.deerflow_client.create_thread(timeout=min(30, self.timeout)) + thread_id = thread.get('thread_id', '') + if not thread_id: + raise errors.DeerFlowAPIError(message=f'DeerFlow create thread 返回数据缺少 thread_id: {thread}') + + query.session.using_conversation.uuid = thread_id + return thread_id + + # ------------------------------------------------------------------ + # 流式事件处理 + # ------------------------------------------------------------------ + + def _handle_values_event( + self, + data: typing.Any, + state: _StreamState, + ) -> str | None: + """处理 values 事件,返回新的完整文本(增量基础上的全量)""" + values_messages = stream_utils.extract_messages_from_values_data(data) + if not values_messages: + return None + + new_messages: list[dict[str, typing.Any]] = [] + if not state.baseline_initialized: + state.baseline_initialized = True + for idx, msg in enumerate(values_messages): + if not isinstance(msg, dict): + continue + new_messages.append(msg) + msg_id = stream_utils.get_message_id(msg) + if msg_id: + self._remember_seen_message_id(state, msg_id) + continue + state.no_id_message_fingerprints[idx] = self._fingerprint_message(msg) + else: + new_messages = self._extract_new_messages_from_values(values_messages, state) + + latest_text = '' + if new_messages: + state.run_values_messages.extend(new_messages) + if len(state.run_values_messages) > _MAX_VALUES_HISTORY: + state.run_values_messages = state.run_values_messages[-_MAX_VALUES_HISTORY:] + latest_text = stream_utils.extract_latest_ai_text(state.run_values_messages) + if latest_text: + state.has_values_text = True + latest_clarification = stream_utils.extract_latest_clarification_text( + state.run_values_messages, + ) + if latest_clarification: + state.clarification_text = latest_clarification + + return latest_text or None + + def _handle_message_event( + self, + data: typing.Any, + state: _StreamState, + ) -> str | None: + """处理 messages-tuple 事件,返回增量文本 + + 当 values 事件已经提供完整文本时,跳过 messages-tuple 的增量 + """ + delta = stream_utils.extract_ai_delta_from_event_data(data) + if delta and not state.has_values_text: + state.latest_text += delta + return delta + + maybe_clar = stream_utils.extract_clarification_from_event_data(data) + if maybe_clar: + state.clarification_text = maybe_clar + return None + + def _build_final_text(self, state: _StreamState) -> str: + """构建最终输出文本""" + if state.clarification_text: + return state.clarification_text + + # 优先使用最后一条 AI message 的文本 + latest_ai = stream_utils.extract_latest_ai_message(state.run_values_messages) + if latest_ai: + text = stream_utils.extract_text(latest_ai.get('content')) + if text: + if state.timed_out: + text += f'\n\nDeerFlow stream 在 {self.timeout}s 后超时,返回部分结果。' + return text + + if state.latest_text: + text = state.latest_text + if state.timed_out: + text += f'\n\nDeerFlow stream 在 {self.timeout}s 后超时,返回部分结果。' + return text + + # 提取任务失败信息作兜底 + failure_text = stream_utils.build_task_failure_summary(state.task_failures) + if failure_text: + return failure_text + + return 'DeerFlow 返回空响应' + + # ------------------------------------------------------------------ + # 主流程 + # ------------------------------------------------------------------ + + async def _stream_messages_chunk( + self, + query: pipeline_query.Query, + ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: + """流式输出生成器""" + plain_text, image_urls = self._preprocess_user_message(query) + + system_prompt = '' + # LangBot 的 pipeline 通常通过 prompt-preprocess 已注入 system prompt + # 这里保持空,让 prompt-preprocess 的内容作为 user message 一并送给 deerflow + + thread_id = await self._ensure_thread_id(query) + payload = self._build_payload( + thread_id=thread_id, + prompt=plain_text or 'continue', + image_urls=image_urls, + system_prompt=system_prompt, + ) + + state = _StreamState() + prev_text = '' + message_idx = 0 + + try: + async for event in self.deerflow_client.stream_run( + thread_id=thread_id, + payload=payload, + timeout=self.timeout, + ): + event_type = event.get('event') + data = event.get('data') + + if event_type == 'values': + new_full = self._handle_values_event(data, state) + if new_full and new_full != prev_text: + delta = new_full[len(prev_text) :] if new_full.startswith(prev_text) else new_full + prev_text = new_full + if delta: + message_idx += 1 + yield provider_message.MessageChunk( + role='assistant', + content=new_full, + is_final=False, + ) + continue + + if event_type in {'messages-tuple', 'messages', 'message'}: + delta = self._handle_message_event(data, state) + if delta: + prev_text = state.latest_text + message_idx += 1 + yield provider_message.MessageChunk( + role='assistant', + content=prev_text, + is_final=False, + ) + continue + + if event_type == 'custom': + state.task_failures.extend( + stream_utils.extract_task_failures_from_custom_event(data), + ) + continue + + if event_type == 'error': + raise errors.DeerFlowAPIError(message=f'DeerFlow stream error event: {data}') + + if event_type == 'end': + break + except (asyncio.TimeoutError, TimeoutError): + self.ap.logger.warning(f'DeerFlow stream timed out after {self.timeout}s for thread_id={thread_id}') + state.timed_out = True + + # 最终消息 + final_text = self._build_final_text(state) + yield provider_message.MessageChunk( + role='assistant', + content=final_text, + is_final=True, + ) + + async def _messages( + self, + query: pipeline_query.Query, + ) -> typing.AsyncGenerator[provider_message.Message, None]: + """非流式聚合输出""" + plain_text, image_urls = self._preprocess_user_message(query) + + thread_id = await self._ensure_thread_id(query) + payload = self._build_payload( + thread_id=thread_id, + prompt=plain_text or 'continue', + image_urls=image_urls, + ) + + state = _StreamState() + + try: + async for event in self.deerflow_client.stream_run( + thread_id=thread_id, + payload=payload, + timeout=self.timeout, + ): + event_type = event.get('event') + data = event.get('data') + + if event_type == 'values': + self._handle_values_event(data, state) + continue + + if event_type in {'messages-tuple', 'messages', 'message'}: + self._handle_message_event(data, state) + continue + + if event_type == 'custom': + state.task_failures.extend( + stream_utils.extract_task_failures_from_custom_event(data), + ) + continue + + if event_type == 'error': + raise errors.DeerFlowAPIError(message=f'DeerFlow stream error event: {data}') + + if event_type == 'end': + break + except (asyncio.TimeoutError, TimeoutError): + self.ap.logger.warning(f'DeerFlow stream timed out after {self.timeout}s for thread_id={thread_id}') + state.timed_out = True + + final_text = self._build_final_text(state) + yield provider_message.Message( + role='assistant', + content=final_text, + ) + + async def run( + self, + query: pipeline_query.Query, + ) -> typing.AsyncGenerator[provider_message.Message, None]: + """主入口:根据 adapter 是否支持流式输出,选择流式或非流式""" + if await query.adapter.is_stream_output_supported(): + msg_idx = 0 + async for msg in self._stream_messages_chunk(query): + msg_idx += 1 + msg.msg_sequence = msg_idx + yield msg + else: + async for msg in self._messages(query): + yield msg diff --git a/src/langbot/pkg/provider/runners/localagent.py b/src/langbot/pkg/provider/runners/localagent.py index b48e9cc3b..3417c6671 100644 --- a/src/langbot/pkg/provider/runners/localagent.py +++ b/src/langbot/pkg/provider/runners/localagent.py @@ -4,7 +4,9 @@ import json import copy import typing from .. import runner +from ...telemetry import features as telemetry_features from ..modelmgr import requester as modelmgr_requester +from ..tools.loaders.native import EXEC_TOOL_NAME import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.builtin.rag.context as rag_context @@ -24,11 +26,164 @@ Respond in the same language as the user's input. """ +SANDBOX_EXEC_TOOL_NAME = 'sandbox_exec' +SANDBOX_EXEC_SYSTEM_GUIDANCE = ( + 'When sandbox_exec is available, use it for exact calculations, statistics, structured data parsing, ' + 'and code execution instead of estimating mentally. If the user provides numbers, tables, CSV-like text, ' + 'JSON, or other data and asks for a computed answer, prefer running a short Python script in sandbox_exec ' + 'and then answer from the tool result.' +) + + +# Hard cap on tool-call rounds within a single agent turn. A looping or +# adversarial model can otherwise emit tool calls indefinitely (each potentially +# a sandbox exec), yielding a non-terminating request and runaway cost. Set +# generously so it never interrupts legitimate multi-step agentic workflows. +MAX_TOOL_CALL_ROUNDS = 128 + + +def _model_has_ability(model: modelmgr_requester.RuntimeLLMModel, ability: str) -> bool: + return ability in (model.model_entity.abilities or []) + + +class _StreamAccumulator: + """Accumulate streamed content and fragmented OpenAI-style tool calls.""" + + def __init__(self, msg_sequence: int = 0, initial_content: str | None = None): + self.tool_calls_map: dict[str, provider_message.ToolCall] = {} + self.msg_idx = 0 + self.accumulated_content = initial_content or '' + self.last_role = 'assistant' + self.msg_sequence = msg_sequence + + def add(self, msg: provider_message.MessageChunk) -> provider_message.MessageChunk | None: + self.msg_idx += 1 + + if msg.role: + self.last_role = msg.role + + if msg.content: + self.accumulated_content += msg.content + + if msg.tool_calls: + for tool_call in msg.tool_calls: + if tool_call.id not in self.tool_calls_map: + self.tool_calls_map[tool_call.id] = provider_message.ToolCall( + id=tool_call.id, + type=tool_call.type, + function=provider_message.FunctionCall( + name=tool_call.function.name if tool_call.function else '', + arguments='', + ), + ) + if tool_call.function and tool_call.function.arguments: + self.tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments + + if self.msg_idx % 8 == 0 or msg.is_final: + self.msg_sequence += 1 + return provider_message.MessageChunk( + role=self.last_role, + content=self.accumulated_content, + tool_calls=list(self.tool_calls_map.values()) if (self.tool_calls_map and msg.is_final) else None, + is_final=msg.is_final, + msg_sequence=self.msg_sequence, + ) + + return None + + def final_message(self) -> provider_message.MessageChunk: + return provider_message.MessageChunk( + role=self.last_role, + content=self.accumulated_content, + tool_calls=list(self.tool_calls_map.values()) if self.tool_calls_map else None, + msg_sequence=self.msg_sequence, + ) + @runner.runner_class('local-agent') class LocalAgentRunner(runner.RequestRunner): """Local agent request runner""" + async def _inject_inbound_attachments( + self, + query: pipeline_query.Query, + user_message: provider_message.Message, + ) -> None: + """Persist inbound attachments into the sandbox and tell the model. + + No-op when the box service is unavailable or there are no attachments. + On success, appends an extra text ContentElement to the user message + listing the in-sandbox paths and the outbox convention, and stashes the + descriptors in ``query.variables['_sandbox_inbound_attachments']``. + """ + box_service = getattr(self.ap, 'box_service', None) + if box_service is None or not getattr(box_service, 'available', False): + return + try: + attachments = await box_service.materialize_inbound_attachments(query) + except Exception as e: # never break the chat turn over attachment IO + self.ap.logger.warning(f'Inbound attachment materialization failed: {e}') + return + if not attachments: + return + + query.variables['_sandbox_inbound_attachments'] = attachments + + lines = [ + 'The user sent attachments. They have been saved into the sandbox and are ' + 'available to the exec/read/write tools at these paths:' + ] + for att in attachments: + lines.append(f'- {att["type"]}: {att["path"]} ({att["size"]} bytes)') + outbox_dir = f'{box_service.OUTBOX_MOUNT_DIR}/{query.query_id}' + lines.append( + 'If you produce any file (image, audio, document, etc.) that should be sent ' + f'back to the user, write it into {outbox_dir}/ (create the directory if ' + 'needed). Every file placed there will be delivered to the user automatically.' + ) + note = '\n'.join(lines) + + # Voice/File attachments are now available to the agent via the sandbox + # (exec/read/write tools). Their raw bytes must NOT be forwarded to the + # chat model as multimodal content: providers reject non-image file + # parts ("Invalid user message ... ensure all user messages are valid + # OpenAI chat completion messages"). Strip those content elements and + # rely on the sandbox-path note instead. Images are kept so vision + # models can still see them. + _model_unsafe_types = {'file_base64', 'file_url'} + if isinstance(user_message.content, list): + user_message.content = [ + ce for ce in user_message.content if getattr(ce, 'type', None) not in _model_unsafe_types + ] + + if isinstance(user_message.content, str): + user_message.content = [ + provider_message.ContentElement.from_text(user_message.content), + provider_message.ContentElement.from_text(note), + ] + elif isinstance(user_message.content, list): + user_message.content.append(provider_message.ContentElement.from_text(note)) + else: + user_message.content = [provider_message.ContentElement.from_text(note)] + + def _build_request_messages( + self, + query: pipeline_query.Query, + user_message: provider_message.Message, + ) -> list[provider_message.Message]: + req_messages = query.prompt.messages.copy() + query.messages.copy() + + if any(getattr(tool, 'name', None) == EXEC_TOOL_NAME for tool in query.use_funcs or []): + req_messages.append( + provider_message.Message( + role='system', + content=self.ap.box_service.get_system_guidance(query.query_id), + ) + ) + + req_messages.append(user_message) + return req_messages + async def _get_model_candidates( self, query: pipeline_query.Query, @@ -71,7 +226,7 @@ class LocalAgentRunner(runner.RequestRunner): query, model, messages, - funcs if model.model_entity.abilities.__contains__('func_call') else [], + funcs if _model_has_ability(model, 'func_call') else [], extra_args=model.model_entity.extra_args, remove_think=remove_think, ) @@ -101,7 +256,7 @@ class LocalAgentRunner(runner.RequestRunner): query, model, messages, - funcs if model.model_entity.abilities.__contains__('func_call') else [], + funcs if _model_has_ability(model, 'func_call') else [], extra_args=model.model_entity.extra_args, remove_think=remove_think, ) @@ -131,6 +286,7 @@ class LocalAgentRunner(runner.RequestRunner): ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: """Run request""" pending_tool_calls = [] + initial_response_emitted = False # Get knowledge bases list from query variables (set by PreProcessor, # may have been modified by plugins during PromptPreProcessing) @@ -138,6 +294,12 @@ class LocalAgentRunner(runner.RequestRunner): user_message = copy.deepcopy(query.user_message) + # Materialize inbound attachments (images / voices / files) into the + # sandbox so the agent's exec/read/write tools can operate on the real + # bytes — not just the multimodal copy the model sees. The exact + # in-sandbox paths are announced to the model as a system note. + await self._inject_inbound_attachments(query, user_message) + user_message_text = '' if isinstance(user_message.content, str): @@ -152,6 +314,8 @@ class LocalAgentRunner(runner.RequestRunner): # only support text for now all_results: list[rag_context.RetrievalResultEntry] = [] + kb_engine_plugins: set[str] = set() + # Retrieve from each knowledge base for kb_uuid in kb_uuids: kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) @@ -160,6 +324,12 @@ class LocalAgentRunner(runner.RequestRunner): self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping') continue + try: + engine_plugin_id = kb.get_knowledge_engine_plugin_id() or 'builtin' + except Exception: + engine_plugin_id = 'builtin' + kb_engine_plugins.add(engine_plugin_id) + result = await kb.retrieve( user_message_text, settings={ @@ -172,6 +342,17 @@ class LocalAgentRunner(runner.RequestRunner): if result: all_results.extend(result) + # Telemetry: knowledge base usage (counts and engine categories only) + telemetry_features.set_value( + query, + 'kb', + { + 'kb_count': len(kb_uuids), + 'engine_plugins': sorted(kb_engine_plugins), + 'retrieved_entries': len(all_results), + }, + ) + # Rerank step: re-score results using a rerank model if configured local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {}) rerank_model_uuid = local_agent_config.get('rerank-model', '') @@ -236,7 +417,31 @@ class LocalAgentRunner(runner.RequestRunner): ce.text = final_user_message_text break - req_messages = query.prompt.messages.copy() + query.messages.copy() + [user_message] + mcp_loader = getattr(getattr(self.ap, 'tool_mgr', None), 'mcp_tool_loader', None) + if mcp_loader is not None: + resource_context = await mcp_loader.build_resource_context_for_query(query) + if resource_context: + resource_addition = ( + '\n\nMCP resource context selected by LangBot host:\n' + f'{resource_context}\n\n' + 'Use this context as read-only reference material. If it conflicts with the user message, ' + 'ask for clarification before taking external actions.' + ) + if isinstance(user_message.content, str): + user_message.content += resource_addition + elif isinstance(user_message.content, list): + appended = False + for ce in user_message.content: + if ce.type == 'text': + ce.text = (ce.text or '') + resource_addition + appended = True + break + if not appended: + user_message.content.append( + provider_message.ContentElement.from_text(resource_addition.strip()) + ) + + req_messages = self._build_request_messages(query, user_message) try: is_stream = await query.adapter.is_stream_output_supported() @@ -264,15 +469,10 @@ class LocalAgentRunner(runner.RequestRunner): query.use_funcs, remove_think, ) - yield msg final_msg = msg else: # Streaming: invoke with fallback - tool_calls_map: dict[str, provider_message.ToolCall] = {} - msg_idx = 0 - accumulated_content = '' - last_role = 'assistant' - msg_sequence = 1 + stream_accumulator = _StreamAccumulator(msg_sequence=1) stream_src, use_llm_model = await self._invoke_stream_with_fallback( query, @@ -282,54 +482,38 @@ class LocalAgentRunner(runner.RequestRunner): remove_think, ) async for msg in stream_src: - msg_idx = msg_idx + 1 + chunk = stream_accumulator.add(msg) + if chunk: + yield chunk + initial_response_emitted = True - if msg.role: - last_role = msg.role - - if msg.content: - accumulated_content += msg.content - - if msg.tool_calls: - for tool_call in msg.tool_calls: - if tool_call.id not in tool_calls_map: - tool_calls_map[tool_call.id] = provider_message.ToolCall( - id=tool_call.id, - type=tool_call.type, - function=provider_message.FunctionCall( - name=tool_call.function.name if tool_call.function else '', arguments='' - ), - ) - if tool_call.function and tool_call.function.arguments: - tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments - - if msg_idx % 8 == 0 or msg.is_final: - msg_sequence += 1 - yield provider_message.MessageChunk( - role=last_role, - content=accumulated_content, - tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None, - is_final=msg.is_final, - msg_sequence=msg_sequence, - ) - - final_msg = provider_message.MessageChunk( - role=last_role, - content=accumulated_content, - tool_calls=list(tool_calls_map.values()) if tool_calls_map else None, - msg_sequence=msg_sequence, - ) + final_msg = stream_accumulator.final_message() pending_tool_calls = final_msg.tool_calls first_content = final_msg.content if isinstance(final_msg, provider_message.MessageChunk): first_end_sequence = final_msg.msg_sequence + if not is_stream: + yield final_msg + elif not initial_response_emitted: + yield final_msg + initial_response_emitted = True + req_messages.append(final_msg) # Once a model succeeds, commit to it for the tool call loop # (no fallback mid-conversation — different models may interpret tool results differently) + tool_call_round = 0 while pending_tool_calls: + tool_call_round += 1 + telemetry_features.set_value(query, 'tool_call_rounds', tool_call_round) + if tool_call_round > MAX_TOOL_CALL_ROUNDS: + self.ap.logger.warning( + f'Tool-call loop reached the {MAX_TOOL_CALL_ROUNDS}-round cap ' + f'(query_id={query.query_id}); stopping to avoid a non-terminating request.' + ) + break for tool_call in pending_tool_calls: try: func = tool_call.function @@ -369,7 +553,15 @@ class LocalAgentRunner(runner.RequestRunner): req_messages.append(msg) except Exception as e: - err_msg = provider_message.Message(role='tool', content=f'err: {e}', tool_call_id=tool_call.id) + if is_stream: + err_msg = provider_message.MessageChunk( + role='tool', + content=f'err: {e}', + tool_call_id=tool_call.id, + is_final=True, + ) + else: + err_msg = provider_message.Message(role='tool', content=f'err: {e}', tool_call_id=tool_call.id) yield err_msg @@ -381,69 +573,32 @@ class LocalAgentRunner(runner.RequestRunner): ) if is_stream: - tool_calls_map = {} - msg_idx = 0 - accumulated_content = '' - last_role = 'assistant' - msg_sequence = first_end_sequence + stream_accumulator = _StreamAccumulator( + msg_sequence=first_end_sequence, + initial_content=first_content, + ) tool_stream_src = use_llm_model.provider.invoke_llm_stream( query, use_llm_model, req_messages, - query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [], + query.use_funcs if _model_has_ability(use_llm_model, 'func_call') else [], extra_args=use_llm_model.model_entity.extra_args, remove_think=remove_think, ) async for msg in tool_stream_src: - msg_idx += 1 + chunk = stream_accumulator.add(msg) + if chunk: + yield chunk - if msg.role: - last_role = msg.role - - # Prepend first-round content on first chunk of tool-call round - if msg_idx == 1: - accumulated_content = first_content if first_content is not None else accumulated_content - - if msg.content: - accumulated_content += msg.content - - if msg.tool_calls: - for tool_call in msg.tool_calls: - if tool_call.id not in tool_calls_map: - tool_calls_map[tool_call.id] = provider_message.ToolCall( - id=tool_call.id, - type=tool_call.type, - function=provider_message.FunctionCall( - name=tool_call.function.name if tool_call.function else '', arguments='' - ), - ) - if tool_call.function and tool_call.function.arguments: - tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments - - if msg_idx % 8 == 0 or msg.is_final: - msg_sequence += 1 - yield provider_message.MessageChunk( - role=last_role, - content=accumulated_content, - tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None, - is_final=msg.is_final, - msg_sequence=msg_sequence, - ) - - final_msg = provider_message.MessageChunk( - role=last_role, - content=accumulated_content, - tool_calls=list(tool_calls_map.values()) if tool_calls_map else None, - msg_sequence=msg_sequence, - ) + final_msg = stream_accumulator.final_message() else: # Non-streaming: use committed model directly (no fallback in tool loop) msg = await use_llm_model.provider.invoke_llm( query, use_llm_model, req_messages, - query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [], + query.use_funcs if _model_has_ability(use_llm_model, 'func_call') else [], extra_args=use_llm_model.model_entity.extra_args, remove_think=remove_think, ) diff --git a/src/langbot/pkg/provider/runners/weknoraapi.py b/src/langbot/pkg/provider/runners/weknoraapi.py new file mode 100644 index 000000000..9d46eebb7 --- /dev/null +++ b/src/langbot/pkg/provider/runners/weknoraapi.py @@ -0,0 +1,351 @@ +from __future__ import annotations + +import typing +import json + + +from langbot.pkg.provider import runner +from langbot.pkg.core import app +import langbot_plugin.api.entities.builtin.provider.message as provider_message +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +from langbot.libs.weknora_api import client, errors + + +@runner.runner_class('weknora-api') +class WeKnoraAPIRunner(runner.RequestRunner): + """WeKnora API 对话请求器""" + + weknora_client: client.AsyncWeKnoraClient + + def __init__(self, ap: app.Application, pipeline_config: dict): + super().__init__(ap, pipeline_config) + + valid_app_types = ['chat', 'agent'] + if self.pipeline_config['ai']['weknora-api']['app-type'] not in valid_app_types: + raise errors.WeKnoraAPIError( + f'不支持的 WeKnora 应用类型: {self.pipeline_config["ai"]["weknora-api"]["app-type"]}' + ) + + api_key = self.pipeline_config['ai']['weknora-api'].get('api-key', '').strip() + if not api_key: + raise errors.WeKnoraAPIError( + 'WeKnora API Key 未配置,请在流水线的 WeKnora API 配置中填入 API Key ' + '(从 WeKnora 前端 设置 → API Keys 生成)' + ) + + base_url = self.pipeline_config['ai']['weknora-api'].get('base-url', '').strip() + if not base_url: + raise errors.WeKnoraAPIError('WeKnora Base URL 未配置,请填入服务器地址,例如 http://localhost:8080/api/v1') + + self.weknora_client = client.AsyncWeKnoraClient( + api_key=api_key, + base_url=base_url, + ) + + async def _extract_plain_text(self, query: pipeline_query.Query) -> str: + """从用户消息中提取纯文本内容""" + plain_text = '' + if isinstance(query.user_message.content, str): + plain_text = query.user_message.content + elif isinstance(query.user_message.content, list): + for ce in query.user_message.content: + if ce.type == 'text': + plain_text += ce.text + + if not plain_text: + plain_text = self.pipeline_config['ai']['weknora-api'].get('base-prompt', '') + + return plain_text + + async def _ensure_session(self, query: pipeline_query.Query) -> str: + """确保会话存在,如果不存在则创建""" + session_id = query.session.using_conversation.uuid or '' + + if not session_id: + user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' + session_id = await self.weknora_client.create_session(title=f'IM Chat - {user_tag}') + query.session.using_conversation.uuid = session_id + + return session_id + + async def _agent_chat_messages( + self, query: pipeline_query.Query + ) -> typing.AsyncGenerator[provider_message.Message, None]: + """调用 Agent 智能对话(非流式聚合输出)""" + session_id = await self._ensure_session(query) + plain_text = await self._extract_plain_text(query) + user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' + + config = self.pipeline_config['ai']['weknora-api'] + agent_id = config.get('agent-id', 'builtin-smart-reasoning') + knowledge_base_ids = config.get('knowledge-base-ids', []) + web_search_enabled = config.get('web-search-enabled', False) + timeout = config.get('timeout', 120) + + full_answer = '' + chunk = None + + async for chunk in self.weknora_client.agent_chat( + session_id=session_id, + query=plain_text, + user=user_tag, + agent_id=agent_id, + knowledge_base_ids=knowledge_base_ids, + web_search_enabled=web_search_enabled, + timeout=timeout, + ): + self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk)) + + response_type = chunk.get('response_type', '') + content = chunk.get('content', '') + + if response_type == 'tool_call': + # 工具调用 + tool_data = chunk.get('data', {}) + tool_name = tool_data.get('tool_name', '') + if tool_name: + yield provider_message.Message( + role='assistant', + tool_calls=[ + provider_message.ToolCall( + id=chunk.get('id', ''), + type='function', + function=provider_message.FunctionCall( + name=tool_name, + arguments=json.dumps(tool_data.get('arguments', {})), + ), + ) + ], + ) + + elif response_type == 'answer': + if content: + full_answer += content + + elif response_type == 'error': + raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}') + + if chunk is None: + raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置') + + if full_answer: + yield provider_message.Message( + role='assistant', + content=full_answer, + ) + + async def _chat_messages( + self, query: pipeline_query.Query + ) -> typing.AsyncGenerator[provider_message.Message, None]: + """调用知识库 RAG 问答(非流式聚合输出)""" + session_id = await self._ensure_session(query) + plain_text = await self._extract_plain_text(query) + user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' + + config = self.pipeline_config['ai']['weknora-api'] + agent_id = config.get('agent-id', 'builtin-quick-answer') + knowledge_base_ids = config.get('knowledge-base-ids', []) + timeout = config.get('timeout', 120) + + full_answer = '' + chunk = None + + async for chunk in self.weknora_client.knowledge_chat( + session_id=session_id, + query=plain_text, + user=user_tag, + agent_id=agent_id, + knowledge_base_ids=knowledge_base_ids, + timeout=timeout, + ): + self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk)) + + response_type = chunk.get('response_type', '') + content = chunk.get('content', '') + + if response_type == 'answer': + if content: + full_answer += content + + elif response_type == 'error': + raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}') + + if chunk is None: + raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置') + + if full_answer: + yield provider_message.Message( + role='assistant', + content=full_answer, + ) + + async def _agent_chat_messages_chunk( + self, query: pipeline_query.Query + ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: + """调用 Agent 智能对话(流式输出)""" + session_id = await self._ensure_session(query) + plain_text = await self._extract_plain_text(query) + user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' + + config = self.pipeline_config['ai']['weknora-api'] + agent_id = config.get('agent-id', 'builtin-smart-reasoning') + knowledge_base_ids = config.get('knowledge-base-ids', []) + web_search_enabled = config.get('web-search-enabled', False) + timeout = config.get('timeout', 120) + + pending_answer = '' + message_idx = 0 + is_final = False + chunk = None + + async for chunk in self.weknora_client.agent_chat( + session_id=session_id, + query=plain_text, + user=user_tag, + agent_id=agent_id, + knowledge_base_ids=knowledge_base_ids, + web_search_enabled=web_search_enabled, + timeout=timeout, + ): + self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk)) + + response_type = chunk.get('response_type', '') + content = chunk.get('content', '') + done = chunk.get('done', False) + + if response_type == 'tool_call': + tool_data = chunk.get('data', {}) + tool_name = tool_data.get('tool_name', '') + if tool_name: + message_idx += 1 + yield provider_message.MessageChunk( + role='assistant', + tool_calls=[ + provider_message.ToolCall( + id=chunk.get('id', ''), + type='function', + function=provider_message.FunctionCall( + name=tool_name, + arguments=json.dumps(tool_data.get('arguments', {})), + ), + ) + ], + ) + + elif response_type == 'answer': + message_idx += 1 + if content: + pending_answer += content + + if done: + is_final = True + + # 每 8 个 chunk 输出一次,或最终输出 + if message_idx % 8 == 0 or is_final: + yield provider_message.MessageChunk( + role='assistant', + content=pending_answer, + is_final=is_final, + ) + + elif response_type == 'error': + raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}') + + if chunk is None: + raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置') + + # 确保最终消息已发出 + if not is_final and pending_answer: + yield provider_message.MessageChunk( + role='assistant', + content=pending_answer, + is_final=True, + ) + + async def _chat_messages_chunk( + self, query: pipeline_query.Query + ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: + """调用知识库 RAG 问答(流式输出)""" + session_id = await self._ensure_session(query) + plain_text = await self._extract_plain_text(query) + user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' + + config = self.pipeline_config['ai']['weknora-api'] + agent_id = config.get('agent-id', 'builtin-quick-answer') + knowledge_base_ids = config.get('knowledge-base-ids', []) + timeout = config.get('timeout', 120) + + pending_answer = '' + message_idx = 0 + is_final = False + chunk = None + + async for chunk in self.weknora_client.knowledge_chat( + session_id=session_id, + query=plain_text, + user=user_tag, + agent_id=agent_id, + knowledge_base_ids=knowledge_base_ids, + timeout=timeout, + ): + self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk)) + + response_type = chunk.get('response_type', '') + content = chunk.get('content', '') + done = chunk.get('done', False) + + if response_type == 'answer': + message_idx += 1 + if content: + pending_answer += content + + if done: + is_final = True + + if message_idx % 8 == 0 or is_final: + yield provider_message.MessageChunk( + role='assistant', + content=pending_answer, + is_final=is_final, + ) + + elif response_type == 'error': + raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}') + + if chunk is None: + raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置') + + if not is_final and pending_answer: + yield provider_message.MessageChunk( + role='assistant', + content=pending_answer, + is_final=True, + ) + + async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: + """运行请求""" + app_type = self.pipeline_config['ai']['weknora-api']['app-type'] + + if await query.adapter.is_stream_output_supported(): + msg_idx = 0 + if app_type == 'agent': + async for msg in self._agent_chat_messages_chunk(query): + msg_idx += 1 + msg.msg_sequence = msg_idx + yield msg + elif app_type == 'chat': + async for msg in self._chat_messages_chunk(query): + msg_idx += 1 + msg.msg_sequence = msg_idx + yield msg + else: + raise errors.WeKnoraAPIError(f'不支持的 WeKnora 应用类型: {app_type}') + else: + if app_type == 'agent': + async for msg in self._agent_chat_messages(query): + yield msg + elif app_type == 'chat': + async for msg in self._chat_messages(query): + yield msg + else: + raise errors.WeKnoraAPIError(f'不支持的 WeKnora 应用类型: {app_type}') diff --git a/src/langbot/pkg/provider/tools/errors.py b/src/langbot/pkg/provider/tools/errors.py new file mode 100644 index 000000000..d44b39ba8 --- /dev/null +++ b/src/langbot/pkg/provider/tools/errors.py @@ -0,0 +1,6 @@ +class ToolNotFoundError(ValueError): + """Raised when a requested tool cannot be found in any active loader.""" + + def __init__(self, name: str): + self.name = name + super().__init__(f'Tool not found: {name}') diff --git a/src/langbot/pkg/provider/tools/loader.py b/src/langbot/pkg/provider/tools/loader.py index 4719d9bb5..f97e82164 100644 --- a/src/langbot/pkg/provider/tools/loader.py +++ b/src/langbot/pkg/provider/tools/loader.py @@ -2,12 +2,17 @@ from __future__ import annotations import abc import typing +from typing import TYPE_CHECKING +from langbot_plugin.api.definition.components.manifest import ComponentManifest from langbot_plugin.api.entities.events import pipeline_query - -from ...core import app import langbot_plugin.api.entities.builtin.resource.tool as resource_tool +if TYPE_CHECKING: + from ...core import app + +ToolLookupResult = resource_tool.LLMTool | ComponentManifest + preregistered_loaders: list[typing.Type[ToolLoader]] = [] @@ -41,6 +46,13 @@ class ToolLoader(abc.ABC): """获取所有工具""" pass + async def get_tool(self, name: str) -> ToolLookupResult | None: + """Get one tool by name.""" + for tool in await self.get_tools(): + if tool.name == name: + return tool + return None + @abc.abstractmethod async def has_tool(self, name: str) -> bool: """检查工具是否存在""" diff --git a/src/langbot/pkg/provider/tools/loaders/availability.py b/src/langbot/pkg/provider/tools/loaders/availability.py new file mode 100644 index 000000000..58d795864 --- /dev/null +++ b/src/langbot/pkg/provider/tools/loaders/availability.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import Any + + +async def is_box_backend_available(ap: Any) -> bool: + """Return whether the configured Box backend is ready for tool execution.""" + box_service = getattr(ap, 'box_service', None) + if box_service is None: + return False + if not getattr(box_service, 'available', False): + return False + try: + status = await box_service.get_status() + backend_info = status.get('backend', {}) + return bool(backend_info.get('available', False)) + except Exception: + return False diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index 46d63b847..5b0a30610 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -1,8 +1,12 @@ from __future__ import annotations +import base64 import enum +import json +import re +import time import typing -from contextlib import AsyncExitStack +from contextlib import AsyncExitStack, asynccontextmanager import traceback from langbot_plugin.api.entities.events import pipeline_query import sqlalchemy @@ -10,16 +14,170 @@ import asyncio import httpx import uuid as uuid_module -from mcp import ClientSession, StdioServerParameters +from mcp import ClientSession, StdioServerParameters, types as mcp_types from mcp.client.stdio import stdio_client from mcp.client.sse import sse_client from mcp.client.streamable_http import streamable_http_client +from mcp.shared.exceptions import McpError +from pydantic import AnyUrl from .. import loader from ....core import app import langbot_plugin.api.entities.builtin.resource.tool as resource_tool import langbot_plugin.api.entities.builtin.provider.message as provider_message from ....entity.persistence import mcp as persistence_mcp +from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase, _ColdStartRetry # noqa: F401 + +# Synthesized LLM tools for MCP resources (not from server tools/list). +# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used. +# Prefixed with langbot_ to avoid clashing with MCP server tool names. +MCP_TOOL_LIST_RESOURCES = 'langbot_mcp_list_resources' +MCP_TOOL_READ_RESOURCE = 'langbot_mcp_read_resource' + +MCP_RESOURCE_DISCOVERY_MAX_PAGES = 20 +MCP_RESOURCE_CACHE_TTL_SECONDS = 30 +MCP_RESOURCE_PREVIEW_MAX_BYTES = 64 * 1024 +MCP_RESOURCE_AGENT_READ_MAX_BYTES = 64 * 1024 +MCP_RESOURCE_AGENT_READ_MAX_TOKENS = 12000 +MCP_RESOURCE_CONTEXT_MAX_TOKENS = 8000 +MCP_RESOURCE_CONTEXT_MAX_BYTES = 96 * 1024 +MCP_RESOURCE_TRACE_QUERY_KEY = '_mcp_resource_reads' +MCP_RESOURCE_LINKS_QUERY_KEY = '_mcp_resource_links' +MCP_RESOURCE_CONTEXT_QUERY_KEY = '_mcp_resource_context' + +TEXT_LIKE_MIME_TYPES = { + 'application/json', + 'application/ld+json', + 'application/xml', + 'application/yaml', + 'application/x-yaml', + 'application/toml', + 'application/javascript', + 'application/typescript', + 'application/sql', + 'application/graphql', +} + +MCP_LIST_RESOURCES_SCHEMA: dict[str, typing.Any] = { + 'type': 'object', + 'properties': { + 'server_name': { + 'type': 'string', + 'description': 'MCP server name as configured in LangBot (see admin / pipeline bindings).', + } + }, + 'required': ['server_name'], +} + +MCP_READ_RESOURCE_SCHEMA: dict[str, typing.Any] = { + 'type': 'object', + 'properties': { + 'server_name': { + 'type': 'string', + 'description': 'MCP server name as configured in LangBot.', + }, + 'uri': { + 'type': 'string', + 'description': 'Resource URI from langbot_mcp_list_resources output or a listed resource template.', + }, + }, + 'required': ['server_name', 'uri'], +} + + +def _mcp_model_dump(obj: typing.Any) -> typing.Any: + if obj is None: + return None + if hasattr(obj, 'model_dump'): + return obj.model_dump(mode='json', by_alias=True, exclude_none=True) + if isinstance(obj, (str, int, float, bool)): + return obj + if isinstance(obj, list): + return [_mcp_model_dump(item) for item in obj] + if isinstance(obj, dict): + return {str(k): _mcp_model_dump(v) for k, v in obj.items()} + return str(obj) + + +def _truncate_text(text: str, max_bytes: int, max_tokens: int | None = None) -> tuple[str, bool, int]: + raw = text.encode('utf-8') + original_bytes = len(raw) + truncated = False + + if max_bytes > 0 and len(raw) > max_bytes: + raw = raw[:max_bytes] + text = raw.decode('utf-8', errors='ignore') + truncated = True + + if max_tokens is not None and max_tokens > 0: + max_chars = max_tokens * 4 + if len(text) > max_chars: + text = text[:max_chars] + truncated = True + + return text, truncated, original_bytes + + +def _blob_size(blob: str) -> int: + try: + return len(base64.b64decode(blob, validate=False)) + except Exception: + return len(blob.encode('utf-8', errors='ignore')) + + +def _resource_to_dict(resource: mcp_types.Resource | mcp_types.ResourceLink) -> dict: + return { + 'uri': str(resource.uri), + 'name': resource.name, + 'title': resource.title or '', + 'description': resource.description or '', + 'mime_type': resource.mimeType or '', + 'size': resource.size, + 'icons': _mcp_model_dump(resource.icons) or [], + 'annotations': _mcp_model_dump(resource.annotations) or {}, + '_meta': _mcp_model_dump(getattr(resource, 'meta', None)) or {}, + } + + +def _resource_template_to_dict(resource_template: mcp_types.ResourceTemplate) -> dict: + return { + 'uri_template': resource_template.uriTemplate, + 'name': resource_template.name, + 'title': resource_template.title or '', + 'description': resource_template.description or '', + 'mime_type': resource_template.mimeType or '', + 'icons': _mcp_model_dump(resource_template.icons) or [], + 'annotations': _mcp_model_dump(resource_template.annotations) or {}, + '_meta': _mcp_model_dump(getattr(resource_template, 'meta', None)) or {}, + } + + +def _is_text_like_mime(mime_type: str) -> bool: + if not mime_type: + return False + normalized = mime_type.split(';', 1)[0].strip().lower() + return normalized.startswith('text/') or normalized in TEXT_LIKE_MIME_TYPES or normalized.endswith('+json') + + +def _uri_matches_template(uri: str, uri_template: str) -> bool: + if uri_template == uri: + return True + if not uri_template or '{' not in uri_template: + return False + + pattern_parts: list[str] = [] + pos = 0 + for match in re.finditer(r'\{[^{}]+\}', uri_template): + pattern_parts.append(re.escape(uri_template[pos : match.start()])) + pattern_parts.append(r'[^\s]+') + pos = match.end() + pattern_parts.append(re.escape(uri_template[pos:])) + return re.fullmatch(''.join(pattern_parts), uri) is not None + + +async def _mcp_resource_tool_placeholder(**kwargs: typing.Any) -> list[provider_message.ContentElement]: + """LLMTool requires a func; real execution goes through MCPLoader.invoke_tool.""" + raise RuntimeError('MCP resource tool execution must be routed through MCPLoader.invoke_tool') class MCPSessionStatus(enum.Enum): @@ -28,6 +186,16 @@ class MCPSessionStatus(enum.Enum): ERROR = 'error' +class _TransportReconnect(Exception): + """Internal signal: the Box stdio WS transport dropped but the managed + process is still alive. Triggers a lightweight transport reconnect that + reuses the live process, instead of a full process rebuild. + + Reconnect attempts are NOT counted toward the fatal retry budget, so a + long-lived session can survive arbitrarily many transient drops. + """ + + class RuntimeMCPSession: """运行时 MCP 会话""" @@ -45,6 +213,12 @@ class RuntimeMCPSession: functions: list[resource_tool.LLMTool] = [] + resources: list[dict] = [] + + resource_templates: list[dict] = [] + + resource_capabilities: dict = {} + enable: bool # connected: bool @@ -58,6 +232,12 @@ class RuntimeMCPSession: error_message: str | None = None + error_phase: MCPSessionErrorPhase | None = None + + retry_count: int = 0 + + _box_stdio_runtime: BoxStdioSessionRuntime + def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application): self.server_name = server_name self.server_uuid = server_config.get('uuid', '') @@ -66,16 +246,63 @@ class RuntimeMCPSession: self.enable = enable self.session = None + # Transient test sessions (created from the config page "test" button, + # which carry no persisted server UUID) must NOT share the live + # "mcp-shared" Box session. Otherwise a failing test churns the shared + # session and tears down healthy, already-connected servers. Callers + # flag these via server_config['_transient'] = True. + self.is_transient = bool(server_config.get('_transient', False)) + self.exit_stack = AsyncExitStack() self.functions = [] + self.resources = [] + self.resource_templates = [] + self.resource_capabilities = {} + self._resource_cache: dict[tuple[str, int, int | None, bool], dict] = {} self.status = MCPSessionStatus.CONNECTING self._lifecycle_task = None self._shutdown_event = asyncio.Event() self._ready_event = asyncio.Event() + # Set transiently when a WS transport drop should NOT stop the managed + # process (it will be re-attached on the next initialize()). + self._preserve_managed_process = False + + # Log buffer for capturing stderr from Box managed process (maxlen=500 keeps + # recent lines without unbounded memory growth) + import collections as _collections + + self._log_buffer: _collections.deque = _collections.deque(maxlen=500) + self._last_stderr_text: str = '' + + self._box_stdio_runtime = BoxStdioSessionRuntime(self) + self.box_config = self._box_stdio_runtime.config async def _init_stdio_python_server(self): + if self._uses_box_stdio(): + await self._box_stdio_runtime.initialize() + return + + # Box is configured (ap.box_service exists) but currently unavailable + # (disabled by config or connection failed). Refuse stdio MCP rather + # than silently falling through to host-stdio — the operator asked + # for the sandbox and the failure mode should be visible. + # + # Set ``error_phase = BOX_UNAVAILABLE`` BEFORE raising so the retry + # wrapper can short-circuit (retrying is pointless when Box is + # deliberately off) and the frontend can render a localized, + # actionable message instead of this raw RuntimeError. Keep the + # message itself short — the frontend ignores it for this phase. + box_service = getattr(self.ap, 'box_service', None) + if box_service is not None and not getattr(box_service, 'available', False): + self.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE + if not getattr(box_service, 'enabled', True): + raise RuntimeError('box_disabled_in_config') + raise RuntimeError('box_unavailable') + + # Legacy: no box_service installed at all (pre-Box dev mode). Fall + # through to host-stdio for backward compatibility. server_params = StdioServerParameters( command=self.server_config['command'], args=self.server_config['args'], @@ -90,6 +317,9 @@ class RuntimeMCPSession: await self.session.initialize() + async def _init_box_stdio_server(self): + await self._box_stdio_runtime.initialize() + async def _init_sse_server(self): sse_transport = await self.exit_stack.enter_async_context( sse_client( @@ -106,77 +336,315 @@ class RuntimeMCPSession: await self.session.initialize() - async def _init_streamable_http_server(self): - transport = await self.exit_stack.enter_async_context( - streamable_http_client( + @asynccontextmanager + async def _streamable_http_session(self) -> typing.AsyncIterator[ClientSession]: + """Enter a fully initialized Streamable HTTP session as one context. + + Initialization must happen inside the same context manager that owns the + MCP transport. The SDK reports request failures by cancelling the host + task and raises the real HTTP error from its TaskGroup during context + exit. Keeping these nested contexts together guarantees a failed + ``__aenter__`` unwinds immediately, so callers see the HTTPStatusError + instead of a detached CancelledError. It also owns the injected HTTPX + client, which the MCP SDK deliberately does not close for callers. + """ + async with httpx.AsyncClient( + headers=self.server_config.get('headers', {}), + timeout=self.server_config.get('timeout', 10), + follow_redirects=True, + ) as http_client: + async with streamable_http_client( self.server_config['url'], - http_client=httpx.AsyncClient( - headers=self.server_config.get('headers', {}), - timeout=self.server_config.get('timeout', 10), - follow_redirects=True, - ), + http_client=http_client, + ) as transport: + read, write, _ = transport + async with ClientSession(read, write) as session: + await session.initialize() + yield session + + async def _init_streamable_http_server(self): + self.session = await self.exit_stack.enter_async_context(self._streamable_http_session()) + + async def _init_remote_server(self): + """Connect to a remote MCP server, auto-detecting the transport. + + The user only supplies a URL ("remote" mode); they should not have to + know whether the server speaks the modern Streamable HTTP transport or + the legacy HTTP+SSE transport. Following the MCP backwards-compatibility + guidance, we try Streamable HTTP first and fall back to SSE when it + fails (e.g. the endpoint returns 4xx to the initialize POST). + """ + try: + await self._init_streamable_http_server() + return + except Exception as e: + if not self._should_fallback_to_sse(e): + self.ap.logger.info( + f'MCP server {self.server_name}: Streamable HTTP transport failed ' + f'({self._describe_exception(e)}); not falling back to SSE' + ) + raise + self.ap.logger.info( + f'MCP server {self.server_name}: Streamable HTTP initialize failed with a compatible HTTP status ' + f'({self._describe_exception(e)}), falling back to legacy SSE' ) - ) - read, write, _ = transport + # The Streamable HTTP attempt may have partially entered the transport / + # session into the exit stack before failing. Tear it down and start + # from a clean stack before trying SSE so we do not leak connections. + try: + await self.exit_stack.aclose() + except Exception as cleanup_err: + self.ap.logger.debug(f'MCP server {self.server_name}: error cleaning up before SSE fallback: {cleanup_err}') + self.exit_stack = AsyncExitStack() + self.session = None - self.session = await self.exit_stack.enter_async_context(ClientSession(read, write)) + await self._init_sse_server() - await self.session.initialize() + _MAX_RETRIES = 3 + _RETRY_DELAYS = [2, 4, 8] async def _lifecycle_loop(self): - """在后台任务中管理整个MCP会话的生命周期""" + """Manage the full MCP session lifecycle in a background task.""" try: if self.server_config['mode'] == 'stdio': await self._init_stdio_python_server() + elif self.server_config['mode'] == 'remote': + await self._init_remote_server() elif self.server_config['mode'] == 'sse': await self._init_sse_server() elif self.server_config['mode'] == 'http': await self._init_streamable_http_server() else: - raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}') + raise ValueError(f'Unknown MCP server mode: {self.server_name}: {self.server_config}') await self.refresh() self.status = MCPSessionStatus.CONNECTED - # 通知start()方法连接已建立 + # Notify start() that connection is established self._ready_event.set() - # 等待shutdown信号 - await self._shutdown_event.wait() + # Wait for shutdown signal, with optional health monitoring for Box stdio + if self._uses_box_stdio(): + monitor_task = asyncio.create_task(self._box_stdio_runtime.monitor_process_health()) + shutdown_task = asyncio.create_task(self._shutdown_event.wait()) + done, pending = await asyncio.wait( + [shutdown_task, monitor_task], + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + for task in done: + if task is monitor_task and not self._shutdown_event.is_set(): + # The monitor completed. This is EITHER the managed + # process actually exiting OR just the WS transport + # dropping while the process stays alive in the Box + # runtime. Re-check the real process state so a + # transient transport drop reconnects (reusing the live + # process) instead of tearing the process down and + # running a full rebuild+backoff cycle. + process_still_running = False + try: + process_still_running = await self._box_stdio_runtime._managed_process_is_running() + except Exception: + process_still_running = False + if process_still_running: + self.ap.logger.info( + f'MCP server {self.server_name}: transport dropped but ' + f'managed process is still running; reconnecting transport' + ) + self.error_phase = MCPSessionErrorPhase.RELAY_CONNECT + # Preserve the live process across the finally-block + # cleanup: only the WS transport should be torn down. + self._preserve_managed_process = True + raise _TransportReconnect('Box managed process transport dropped; reconnecting') + self.error_phase = MCPSessionErrorPhase.RUNTIME + raise Exception('Box managed process exited unexpectedly') + else: + await self._shutdown_event.wait() + except _ColdStartRetry: + # Cold-start in progress: set the preserve flag BEFORE the finally + # block runs so it does not stop the live managed process. The outer + # _lifecycle_loop_with_retry will reuse it on the next attempt. + self._preserve_managed_process = True + raise except Exception as e: self.status = MCPSessionStatus.ERROR self.error_message = str(e) self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}') - # 即使出错也要设置ready事件,让start()方法知道初始化已完成 - self._ready_event.set() + # Do NOT set _ready_event here — let _lifecycle_loop_with_retry + # handle retries first. It will set the event when all retries + # are exhausted or on success. + raise # Re-raise so _lifecycle_loop_with_retry can catch it finally: - # 在同一个任务中清理所有资源 + # Clean up all resources in the same task try: if self.exit_stack: await self.exit_stack.aclose() + self.exit_stack = AsyncExitStack() self.functions.clear() + self.resources.clear() self.session = None except Exception as e: self.ap.logger.error(f'Error cleaning up MCP session {self.server_name}: {e}\n{traceback.format_exc()}') + finally: + # On a transport-only reconnect the managed process is healthy + # and will be re-attached on the next initialize(); do NOT stop + # it. Any other exit path fully tears the session down. + if getattr(self, '_preserve_managed_process', False): + self._preserve_managed_process = False + else: + await self._cleanup_box_stdio_session() + + async def _lifecycle_loop_with_retry(self): + """Wrap _lifecycle_loop with retry and exponential backoff.""" + attempt = 0 + while attempt <= self._MAX_RETRIES: + try: + await self._lifecycle_loop() + return # Normal shutdown, don't retry + except _TransportReconnect as e: + # Transient WS transport drop while the managed process is still + # alive. Reconnect promptly WITHOUT consuming the fatal retry + # budget and WITHOUT stopping the process — initialize() will + # re-attach to the live process. This is what lets a long-lived + # stdio MCP survive repeated brief event-loop stalls / pings. + if self._shutdown_event.is_set(): + return + self.ap.logger.info( + f'MCP session {self.server_name}: reconnecting transport ({self._describe_exception(e)})' + ) + self.status = MCPSessionStatus.CONNECTING + self.error_message = None + self.error_phase = None + await asyncio.sleep(1) + continue + except _ColdStartRetry as e: + # The managed process is alive but still cold-starting (e.g. + # `npx -y ` is still installing) and cannot yet answer the + # handshake. Reuse the live process and retry the attach WITHOUT + # consuming the fatal retry budget or stopping the process, so a + # slow cold start is waited out instead of failing. Preserve the + # process across the finally-block cleanup. + if self._shutdown_event.is_set(): + return + self._preserve_managed_process = True + self.ap.logger.debug( + f'MCP session {self.server_name}: waiting for cold start ({self._describe_exception(e)})' + ) + self.status = MCPSessionStatus.CONNECTING + self.error_message = None + self.error_phase = None + await asyncio.sleep(2) + continue + except Exception as e: + self.retry_count = attempt + 1 + if self._shutdown_event.is_set(): + return # Shutdown requested, don't retry + # BOX_UNAVAILABLE is a deliberate refusal, not a transient + # failure — retrying produces log spam and a misleading + # "Failed after N attempts" message. Surface it immediately. + if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE: + self.status = MCPSessionStatus.ERROR + self.error_message = str(e) + self._ready_event.set() + return + if attempt >= self._MAX_RETRIES: + self.status = MCPSessionStatus.ERROR + self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {self._describe_exception(e)}' + self._ready_event.set() + return + delay = self._RETRY_DELAYS[attempt] + self.ap.logger.warning( + f'MCP session {self.server_name} failed (attempt {attempt + 1}), ' + f'retrying in {delay}s: {self._describe_exception(e)}' + ) + await self._cleanup_box_stdio_session() + # Reset status for retry + self.status = MCPSessionStatus.CONNECTING + self.error_message = None + self.error_phase = None + await asyncio.sleep(delay) + attempt += 1 + + @staticmethod + def _describe_exception(exc: BaseException) -> str: + """Flatten an exception into its underlying leaf messages. + + anyio / the MCP client wrap real failures in a TaskGroup, whose own + message is the unhelpful "unhandled errors in a TaskGroup (N + sub-exception)". Recurse into ExceptionGroups so the actual cause + (e.g. ``httpx.HTTPStatusError: Client error '410 Gone'``) is surfaced. + """ + leaves: list[str] = [] + + def visit(e: BaseException) -> None: + sub = getattr(e, 'exceptions', None) + if sub: # ExceptionGroup / BaseExceptionGroup + for child in sub: + visit(child) + else: + leaves.append(f'{type(e).__name__}: {e}') + + visit(exc) + seen: set[str] = set() + unique = [m for m in leaves if not (m in seen or seen.add(m))] + return '; '.join(unique) if unique else f'{type(exc).__name__}: {exc}' + + @staticmethod + def _iter_exception_leaves(exc: BaseException) -> typing.Iterator[BaseException]: + sub = getattr(exc, 'exceptions', None) + if sub: # ExceptionGroup / BaseExceptionGroup + for child in sub: + yield from RuntimeMCPSession._iter_exception_leaves(child) + else: + yield exc + + @staticmethod + def _should_fallback_to_sse(exc: BaseException) -> bool: + """Whether a Streamable HTTP failure matches legacy-SSE fallback. + + Only protocol-compatibility responses trigger fallback. Authentication, + authorization, throttling, and server failures must remain visible + instead of being retried against a different transport. + + MCP SDK 1.26 translates an HTTP 404 initialize response into a synthetic + ``McpError(32600, 'Session terminated')`` rather than preserving the + HTTPStatusError, so recognize that exact SDK sentinel as 404-compatible. + """ + fallback_statuses = {400, 404, 405} + for leaf in RuntimeMCPSession._iter_exception_leaves(exc): + if isinstance(leaf, httpx.HTTPStatusError): + if leaf.response.status_code in fallback_statuses: + return True + elif isinstance(leaf, McpError) and leaf.error.code == 32600 and leaf.error.message == 'Session terminated': + return True + return False + + _MONITOR_POLL_INTERVAL = 5 + _MONITOR_MAX_CONSECUTIVE_ERRORS = 3 + + async def _monitor_box_process_health(self): + await self._box_stdio_runtime.monitor_process_health() async def start(self): if not self.enable: return - # 创建后台任务来管理生命周期 - self._lifecycle_task = asyncio.create_task(self._lifecycle_loop()) + # Create background task for lifecycle management with retry + self._lifecycle_task = asyncio.create_task(self._lifecycle_loop_with_retry()) - # 等待连接建立或失败(带超时) + # Wait for connection or failure (with timeout) + startup_timeout = (self.box_config.startup_timeout_sec + 30) if self._uses_box_stdio() else 30.0 try: - await asyncio.wait_for(self._ready_event.wait(), timeout=30.0) + await asyncio.wait_for(self._ready_event.wait(), timeout=startup_timeout) except asyncio.TimeoutError: self.status = MCPSessionStatus.ERROR - raise Exception('Connection timeout after 30 seconds') + raise Exception(f'Connection timeout after {startup_timeout} seconds') - # 检查是否有错误 + # Check for errors if self.status == MCPSessionStatus.ERROR: raise Exception('Connection failed, please check URL') @@ -185,6 +653,15 @@ class RuntimeMCPSession: return self.functions.clear() + self.resources.clear() + self.resource_templates.clear() + self._resource_cache.clear() + + try: + capabilities = self.session.get_server_capabilities() + self.resource_capabilities = _mcp_model_dump(getattr(capabilities, 'resources', None)) or {} + except Exception: + self.resource_capabilities = {} tools = await self.session.list_tools() @@ -193,28 +670,7 @@ class RuntimeMCPSession: for tool in tools.tools: async def func(*, _tool=tool, **kwargs): - if not self.session: - raise Exception('MCP session is not connected') - - result = await self.session.call_tool(_tool.name, kwargs) - if result.isError: - error_texts = [] - for content in result.content: - if content.type == 'text': - error_texts.append(content.text) - raise Exception('\n'.join(error_texts) if error_texts else 'Unknown error from MCP tool') - - result_contents: list[provider_message.ContentElement] = [] - for content in result.content: - if content.type == 'text': - result_contents.append(provider_message.ContentElement.from_text(content.text)) - elif content.type == 'image': - result_contents.append(provider_message.ContentElement.from_image_base64(content.image_base64)) - elif content.type == 'resource': - # TODO: Handle resource content - pass - - return result_contents + return await self.invoke_mcp_tool(_tool.name, kwargs) func.__name__ = tool.name @@ -228,22 +684,360 @@ class RuntimeMCPSession: ) ) + await self._refresh_resources() + + async def _refresh_resources(self): + if not self.session: + return + + try: + cursor: str | None = None + for _ in range(MCP_RESOURCE_DISCOVERY_MAX_PAGES): + resources_result = await self.session.list_resources(cursor) + for resource in resources_result.resources: + self.resources.append(_resource_to_dict(resource)) + cursor = getattr(resources_result, 'nextCursor', None) + if not cursor: + break + self.ap.logger.debug(f'Refresh MCP resources: {len(self.resources)} resources found') + except Exception as e: + self.ap.logger.debug(f'MCP server {self.server_name} does not support resources or failed to list: {e}') + + try: + cursor = None + for _ in range(MCP_RESOURCE_DISCOVERY_MAX_PAGES): + templates_result = await self.session.list_resource_templates(cursor) + for template in templates_result.resourceTemplates: + self.resource_templates.append(_resource_template_to_dict(template)) + cursor = getattr(templates_result, 'nextCursor', None) + if not cursor: + break + self.ap.logger.debug(f'Refresh MCP resource templates: {len(self.resource_templates)} templates found') + except Exception as e: + self.ap.logger.debug( + f'MCP server {self.server_name} does not support resource templates or failed to list: {e}' + ) + + def _record_query_resource_link( + self, + query: pipeline_query.Query | None, + resource_link: dict, + source_tool: str, + ) -> None: + if query is None: + return + try: + link = { + **resource_link, + 'server_name': self.server_name, + 'server_uuid': self.server_uuid, + 'source_tool': source_tool, + } + query.variables.setdefault(MCP_RESOURCE_LINKS_QUERY_KEY, []).append(link) + except Exception: + pass + + def _content_to_provider_elements( + self, + content: typing.Any, + *, + query: pipeline_query.Query | None = None, + source_tool: str = '', + ) -> list[provider_message.ContentElement]: + content_type = getattr(content, 'type', '') + if content_type == 'text': + return [provider_message.ContentElement.from_text(content.text)] + + if content_type == 'image': + image_data = getattr(content, 'data', None) or getattr(content, 'image_base64', None) + if image_data: + return [provider_message.ContentElement.from_image_base64(image_data)] + return [] + + if content_type == 'audio': + return [ + provider_message.ContentElement.from_text( + json.dumps( + { + 'type': 'audio', + 'mime_type': getattr(content, 'mimeType', ''), + 'message': 'Audio content returned by MCP tool is available to the host but not inlined.', + }, + ensure_ascii=False, + ) + ) + ] + + if content_type == 'resource_link': + resource_link = _resource_to_dict(content) + self._record_query_resource_link(query, resource_link, source_tool) + return [ + provider_message.ContentElement.from_text( + json.dumps( + { + 'type': 'resource_link', + 'server_name': self.server_name, + 'server_uuid': self.server_uuid, + 'resource': resource_link, + 'message': 'Resource link captured. Read it only if the task needs this additional context.', + }, + ensure_ascii=False, + indent=2, + ) + ) + ] + + if content_type == 'resource': + resource = getattr(content, 'resource', None) + if isinstance(resource, mcp_types.TextResourceContents): + text, truncated, original_bytes = _truncate_text( + resource.text, + MCP_RESOURCE_AGENT_READ_MAX_BYTES, + MCP_RESOURCE_AGENT_READ_MAX_TOKENS, + ) + header = { + 'type': 'embedded_resource', + 'server_name': self.server_name, + 'server_uuid': self.server_uuid, + 'uri': str(resource.uri), + 'mime_type': resource.mimeType or '', + 'bytes': original_bytes, + 'truncated': truncated, + } + return [provider_message.ContentElement.from_text(f'{json.dumps(header, ensure_ascii=False)}\n{text}')] + if isinstance(resource, mcp_types.BlobResourceContents): + return [ + provider_message.ContentElement.from_text( + json.dumps( + { + 'type': 'embedded_resource', + 'server_name': self.server_name, + 'server_uuid': self.server_uuid, + 'uri': str(resource.uri), + 'mime_type': resource.mimeType or '', + 'bytes': _blob_size(resource.blob), + 'binary_omitted': True, + }, + ensure_ascii=False, + ) + ) + ] + + return [] + + async def invoke_mcp_tool( + self, + tool_name: str, + arguments: dict, + query: pipeline_query.Query | None = None, + ) -> list[provider_message.ContentElement]: + if not self.session: + raise Exception('MCP session is not connected') + + result = await self.session.call_tool(tool_name, arguments) + if result.isError: + error_texts = [] + for content in result.content: + if getattr(content, 'type', '') == 'text': + error_texts.append(content.text) + raise Exception('\n'.join(error_texts) if error_texts else 'Unknown error from MCP tool') + + result_contents: list[provider_message.ContentElement] = [] + for content in result.content: + result_contents.extend(self._content_to_provider_elements(content, query=query, source_tool=tool_name)) + return result_contents + def get_tools(self) -> list[resource_tool.LLMTool]: return self.functions + def get_resources(self) -> list[dict]: + return self.resources + + def get_resource_templates(self) -> list[dict]: + return self.resource_templates + + def has_resource_support(self) -> bool: + return bool(self.resources or self.resource_templates or self.resource_capabilities) + + def invalidate_resource_cache(self, uri: str | None = None) -> None: + if uri is None: + self._resource_cache.clear() + return + for key in list(self._resource_cache.keys()): + if key[0] == uri: + self._resource_cache.pop(key, None) + + def resource_uri_allowed(self, uri: str) -> bool: + if any(item.get('uri') == uri for item in self.resources): + return True + + for template in self.resource_templates: + uri_template = template.get('uri_template', '') + if _uri_matches_template(uri, uri_template): + return True + + return False + + async def read_resource_envelope( + self, + uri: str, + *, + max_bytes: int = MCP_RESOURCE_PREVIEW_MAX_BYTES, + max_tokens: int | None = None, + include_blob: bool = False, + source: str = 'api', + query: pipeline_query.Query | None = None, + ) -> dict: + """Read a resource by URI with safety limits and audit metadata.""" + if not self.session: + raise Exception('MCP session is not connected') + + if not self.resource_uri_allowed(uri): + raise ValueError( + f'Resource URI is not available from MCP server {self.server_name!r}: {uri!r}. ' + 'Use listed resources or resource templates.' + ) + + cache_key = (uri, max_bytes, max_tokens, include_blob) + now = time.time() + cached = self._resource_cache.get(cache_key) + if cached and now - cached.get('cached_at', 0) <= MCP_RESOURCE_CACHE_TTL_SECONDS: + envelope = { + **cached['envelope'], + 'cache_hit': True, + 'source': source, + } + self._record_resource_read_trace(query, envelope) + return envelope + + result = await self.session.read_resource(AnyUrl(uri)) + contents: list[dict] = [] + total_bytes = 0 + truncated_any = False + warnings: list[str] = [] + remaining_bytes = max_bytes if max_bytes > 0 else None + remaining_tokens = max_tokens if max_tokens is not None and max_tokens > 0 else None + + for content in result.contents: + if isinstance(content, mcp_types.TextResourceContents): + if (remaining_bytes is not None and remaining_bytes <= 0) or ( + remaining_tokens is not None and remaining_tokens <= 0 + ): + text = '' + truncated = True + original_bytes = len(content.text.encode('utf-8')) + else: + text, truncated, original_bytes = _truncate_text( + content.text, + remaining_bytes if remaining_bytes is not None else 0, + remaining_tokens, + ) + total_bytes += original_bytes + truncated_any = truncated_any or truncated + if remaining_bytes is not None: + remaining_bytes = max(0, remaining_bytes - len(text.encode('utf-8'))) + if remaining_tokens is not None: + remaining_tokens = max(0, remaining_tokens - (max(1, len(text) // 4) if text else 0)) + contents.append( + { + 'uri': str(content.uri), + 'mime_type': content.mimeType or '', + 'type': 'text', + 'text': text, + 'bytes': original_bytes, + 'truncated': truncated, + '_meta': _mcp_model_dump(getattr(content, 'meta', None)) or {}, + } + ) + elif isinstance(content, mcp_types.BlobResourceContents): + original_bytes = _blob_size(content.blob) + total_bytes += original_bytes + include_this_blob = include_blob and (remaining_bytes is None or original_bytes <= remaining_bytes) + if not include_this_blob: + truncated_any = True + warnings.append('Binary resource content omitted from response.') + elif remaining_bytes is not None: + remaining_bytes = max(0, remaining_bytes - original_bytes) + contents.append( + { + 'uri': str(content.uri), + 'mime_type': content.mimeType or '', + 'type': 'blob', + 'blob': content.blob if include_this_blob else None, + 'bytes': original_bytes, + 'truncated': not include_this_blob, + 'binary_omitted': not include_this_blob, + '_meta': _mcp_model_dump(getattr(content, 'meta', None)) or {}, + } + ) + + envelope = { + 'server_name': self.server_name, + 'server_uuid': self.server_uuid, + 'uri': uri, + 'source': source, + 'contents': contents, + 'bytes': total_bytes, + 'truncated': truncated_any, + 'cache_hit': False, + 'warnings': warnings, + } + self._resource_cache[cache_key] = {'cached_at': now, 'envelope': envelope} + self._record_resource_read_trace(query, envelope) + return envelope + + def _record_resource_read_trace(self, query: pipeline_query.Query | None, envelope: dict) -> None: + if query is None: + return + try: + from langbot.pkg.telemetry import features as telemetry_features + + telemetry_features.increment(query, 'mcp_resource_reads', envelope.get('source') or 'unknown') + query.variables.setdefault(MCP_RESOURCE_TRACE_QUERY_KEY, []).append( + { + 'server_name': envelope.get('server_name'), + 'server_uuid': envelope.get('server_uuid'), + 'uri': envelope.get('uri'), + 'source': envelope.get('source'), + 'bytes': envelope.get('bytes', 0), + 'truncated': envelope.get('truncated', False), + 'cache_hit': envelope.get('cache_hit', False), + 'content_types': [item.get('type') for item in envelope.get('contents', [])], + } + ) + except Exception: + pass + + async def read_resource(self, uri: str) -> list[dict]: + """Read a resource by URI and return its capped contents.""" + envelope = await self.read_resource_envelope(uri) + return envelope['contents'] + def get_runtime_info_dict(self) -> dict: - return { + info = { 'status': self.status.value, 'error_message': self.error_message, + 'error_phase': self.error_phase.value if self.error_phase else None, + 'retry_count': self.retry_count, 'tool_count': len(self.get_tools()), 'tools': [ { 'name': tool.name, 'description': tool.description, + 'parameters': tool.parameters, } for tool in self.get_tools() ], + 'resource_count': len(self.get_resources()), + 'resources': self.get_resources(), + 'resource_template_count': len(self.get_resource_templates()), + 'resource_templates': self.get_resource_templates(), + 'resource_capabilities': self.resource_capabilities, } + if self._uses_box_stdio(): + info['box_session_id'] = self._build_box_session_id() + info['box_enabled'] = True + return info async def shutdown(self): """关闭会话并清理资源""" @@ -267,6 +1061,49 @@ class RuntimeMCPSession: except Exception as e: self.ap.logger.error(f'Error shutting down MCP session {self.server_name}: {e}\n{traceback.format_exc()}') + def _uses_box_stdio(self) -> bool: + return self._box_stdio_runtime.uses_box_stdio() + + def _build_box_session_id(self) -> str: + # Both live servers and transient config-page tests share ONE Box + # session ('mcp-shared'). A test therefore reuses the already-running + # container (and, for an existing server, its live managed process) + # instead of paying a full per-test session cold-start + dependency + # bootstrap. Isolation between a test and the live servers is provided + # at the *process* level: each server/test has its own process_id and a + # test only ever stops its own process_id (see cleanup_session), so it + # never disturbs another server's process or the shared session itself. + return 'mcp-shared' + + def _rewrite_path(self, path: str, host_path: str | None) -> str: + return self._box_stdio_runtime.rewrite_path(path, host_path) + + def _infer_host_path(self) -> str | None: + return self._box_stdio_runtime.infer_host_path() + + @staticmethod + def _unwrap_venv_path(directory: str) -> str: + return BoxStdioSessionRuntime.unwrap_venv_path(directory) + + def _resolve_host_path(self) -> str | None: + return self._box_stdio_runtime.resolve_host_path() + + @staticmethod + def _detect_install_command(host_path: str) -> str | None: + return BoxStdioSessionRuntime.detect_install_command(host_path) + + def _build_box_session_payload(self, session_id: str, host_path: str | None = None) -> dict: + return self._box_stdio_runtime.build_box_session_payload(session_id, host_path) + + def _build_box_process_payload(self, host_path: str | None = None) -> dict: + return self._box_stdio_runtime.build_box_process_payload(host_path) + + def _rewrite_venv_command(self, command: str, host_path: str) -> str: + return self._box_stdio_runtime.rewrite_venv_command(command, host_path) + + async def _cleanup_box_stdio_session(self) -> None: + await self._box_stdio_runtime.cleanup_session() + # @loader.loader_class('mcp') class MCPLoader(loader.ToolLoader): @@ -332,15 +1169,19 @@ class MCPLoader(loader.ToolLoader): Args: server_config: 服务器配置字典,必须包含: - name: 服务器名称 - - mode: 连接模式 (stdio/sse) + - mode: 连接模式 (stdio/sse/http) - enable: 是否启用 - extra_args: 额外的配置参数 (可选) """ uuid_ = server_config.get('uuid') + is_transient = False if not uuid_: self.ap.logger.warning('Server UUID is None for MCP server, maybe testing in the config page.') uuid_ = str(uuid_module.uuid4()) server_config['uuid'] = uuid_ + # No persisted UUID => this is a throwaway "test" session from the + # config page. Isolate it from the shared live Box session. + is_transient = True name = server_config['name'] uuid = server_config['uuid'] @@ -353,6 +1194,7 @@ class MCPLoader(loader.ToolLoader): 'uuid': uuid, 'mode': mode, 'enable': enable, + '_transient': is_transient, **extra_args, } @@ -360,8 +1202,164 @@ class MCPLoader(loader.ToolLoader): return session - async def get_tools(self, bound_mcp_servers: list[str] | None = None) -> list[resource_tool.LLMTool]: - all_functions = [] + @staticmethod + def _get_bound_mcp_from_query(query: pipeline_query.Query) -> list[str] | None: + v = getattr(query, 'variables', None) or {} + return v.get('_pipeline_bound_mcp_servers', None) + + def _eligible_sessions_for_bound(self, bound_mcp_servers: list[str] | None) -> list[RuntimeMCPSession]: + out: list[RuntimeMCPSession] = [] + for session in self.sessions.values(): + if not session.enable: + continue + if session.status != MCPSessionStatus.CONNECTED: + continue + if session.session is None: + continue + if bound_mcp_servers is not None and session.server_uuid not in bound_mcp_servers: + continue + out.append(session) + return out + + def _eligible_resource_sessions_for_bound(self, bound_mcp_servers: list[str] | None) -> list[RuntimeMCPSession]: + return [ + session + for session in self._eligible_sessions_for_bound(bound_mcp_servers) + if session.has_resource_support() + ] + + @staticmethod + def _mcp_synthetic_resource_tools() -> list[resource_tool.LLMTool]: + return [ + resource_tool.LLMTool( + name=MCP_TOOL_LIST_RESOURCES, + human_desc='List MCP resource URIs for a server (MCP resources/list).', + description=( + 'Lists resources and resource templates exposed by an MCP server. ' + 'Call langbot_mcp_read_resource with a listed resource URI or a URI constructed from a listed template. ' + 'Use the server name from LangBot pipeline MCP bindings or admin configuration.' + ), + parameters=MCP_LIST_RESOURCES_SCHEMA, + func=_mcp_resource_tool_placeholder, + ), + resource_tool.LLMTool( + name=MCP_TOOL_READ_RESOURCE, + human_desc='Read a single MCP resource by URI (MCP resources/read).', + description=( + 'Fetches capped text content for a resource. Binary resources return metadata only. ' + 'Only read URIs exposed by langbot_mcp_list_resources for the bound server.' + ), + parameters=MCP_READ_RESOURCE_SCHEMA, + func=_mcp_resource_tool_placeholder, + ), + ] + + async def _invoke_mcp_list_resources(self, parameters: dict, query: pipeline_query.Query) -> typing.Any: + server_name = parameters.get('server_name') if parameters else None + if not server_name or not isinstance(server_name, str): + return [provider_message.ContentElement.from_text('Error: "server_name" (string) is required.')] + + bound = self._get_bound_mcp_from_query(query) + allowed = {s.server_name for s in self._eligible_resource_sessions_for_bound(bound)} + if server_name not in allowed: + return [ + provider_message.ContentElement.from_text( + f'Error: MCP server {server_name!r} is not available for this query. ' + f'Allowed server names: {sorted(allowed)}. ' + 'Check pipeline MCP server bindings and that the server is connected.' + ) + ] + + session = self.get_session(server_name) + if session is None or session.status != MCPSessionStatus.CONNECTED: + return [provider_message.ContentElement.from_text(f'Error: MCP server not connected: {server_name!r}')] + + data = session.get_resources() + templates = session.get_resource_templates() + body = { + 'server_name': server_name, + 'resource_count': len(data), + 'resources': data, + 'resource_template_count': len(templates), + 'resource_templates': templates, + 'resource_capabilities': session.resource_capabilities, + } + return [provider_message.ContentElement.from_text(json.dumps(body, ensure_ascii=False, indent=2))] + + async def _invoke_mcp_read_resource(self, parameters: dict, query: pipeline_query.Query) -> typing.Any: + server_name = parameters.get('server_name') if parameters else None + uri = parameters.get('uri') if parameters else None + if not server_name or not isinstance(server_name, str): + return [provider_message.ContentElement.from_text('Error: "server_name" (string) is required.')] + if not uri or not isinstance(uri, str): + return [provider_message.ContentElement.from_text('Error: "uri" (string) is required.')] + + bound = self._get_bound_mcp_from_query(query) + allowed = {s.server_name for s in self._eligible_resource_sessions_for_bound(bound)} + if server_name not in allowed: + return [ + provider_message.ContentElement.from_text( + f'Error: MCP server {server_name!r} is not available for this query. ' + f'Allowed server names: {sorted(allowed)}.' + ) + ] + + session = self.get_session(server_name) + if session is None or session.status != MCPSessionStatus.CONNECTED: + return [provider_message.ContentElement.from_text(f'Error: MCP server not connected: {server_name!r}')] + + try: + envelope = await session.read_resource_envelope( + uri, + max_bytes=MCP_RESOURCE_AGENT_READ_MAX_BYTES, + max_tokens=MCP_RESOURCE_AGENT_READ_MAX_TOKENS, + include_blob=False, + source='agent_tool', + query=query, + ) + except Exception as e: + self.ap.logger.error(f'read_resource {uri!r} on {server_name}: {e}\n{traceback.format_exc()}') + return [provider_message.ContentElement.from_text(f'Error reading resource: {e!s}')] + + out_chunks: list[str] = [] + for item in envelope.get('contents', []): + if not isinstance(item, dict): + continue + t = item.get('type', '') + if t == 'text' and 'text' in item: + header = { + 'uri': item.get('uri'), + 'mime_type': item.get('mime_type', ''), + 'bytes': item.get('bytes', 0), + 'truncated': item.get('truncated', False), + } + out_chunks.append(f'{json.dumps(header, ensure_ascii=False)}\n{typing.cast(str, item["text"])}') + elif t == 'blob': + out_chunks.append( + json.dumps( + { + 'uri': item.get('uri'), + 'mime_type': item.get('mime_type', ''), + 'bytes': item.get('bytes', 0), + 'binary_omitted': True, + }, + ensure_ascii=False, + ) + ) + if not out_chunks: + return [provider_message.ContentElement.from_text(json.dumps(envelope, ensure_ascii=False, indent=2))] + suffix = '' + if envelope.get('truncated'): + suffix = '\n\n[LangBot: resource content was truncated by configured byte/token limits.]' + return [provider_message.ContentElement.from_text('\n\n'.join(out_chunks) + suffix)] + + async def get_tools( + self, + bound_mcp_servers: list[str] | None = None, + *, + include_resource_tools: bool = True, + ) -> list[resource_tool.LLMTool]: + all_functions: list[resource_tool.LLMTool] = [] for session in self.sessions.values(): # If bound_mcp_servers is specified, only include tools from those servers @@ -372,26 +1370,87 @@ class MCPLoader(loader.ToolLoader): # If no bound servers specified, include all tools all_functions.extend(session.get_tools()) + if include_resource_tools and self._eligible_resource_sessions_for_bound(bound_mcp_servers): + all_functions.extend(self._mcp_synthetic_resource_tools()) + self._last_listed_functions = all_functions return all_functions + async def get_tool_catalog( + self, + bound_mcp_servers: list[str] | None = None, + *, + include_resource_tools: bool = False, + ) -> list[dict[str, typing.Any]]: + items: list[dict[str, typing.Any]] = [] + + for session in self.sessions.values(): + if bound_mcp_servers is not None and session.server_uuid not in bound_mcp_servers: + continue + for tool in session.get_tools(): + items.append( + { + 'name': tool.name, + 'description': tool.description, + 'human_desc': tool.human_desc, + 'parameters': tool.parameters, + 'source': 'mcp', + 'source_name': session.server_name, + 'source_id': session.server_uuid, + } + ) + + if include_resource_tools and self._eligible_resource_sessions_for_bound(bound_mcp_servers): + for tool in self._mcp_synthetic_resource_tools(): + items.append( + { + 'name': tool.name, + 'description': tool.description, + 'human_desc': tool.human_desc, + 'parameters': tool.parameters, + 'source': 'mcp', + 'source_name': 'MCP resources', + 'source_id': '', + } + ) + + return items + async def has_tool(self, name: str) -> bool: """检查工具是否存在""" + if name in (MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE): + return bool(self._eligible_resource_sessions_for_bound(None)) for session in self.sessions.values(): for function in session.get_tools(): if function.name == name: return True return False + async def get_tool(self, name: str) -> resource_tool.LLMTool | None: + for session in self.sessions.values(): + for function in session.get_tools(): + if function.name == name: + return function + return None + async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any: """执行工具调用""" + if name == MCP_TOOL_LIST_RESOURCES: + if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False: + return [provider_message.ContentElement.from_text('Error: MCP resource agent reads are disabled.')] + return await self._invoke_mcp_list_resources(parameters, query) + if name == MCP_TOOL_READ_RESOURCE: + if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False: + return [provider_message.ContentElement.from_text('Error: MCP resource agent reads are disabled.')] + return await self._invoke_mcp_read_resource(parameters, query) + for session in self.sessions.values(): for function in session.get_tools(): if function.name == name: self.ap.logger.debug(f'Invoking MCP tool: {name} with parameters: {parameters}') try: - result = await function.func(**parameters) + result = await session.invoke_mcp_tool(name, parameters, query=query) self.ap.logger.debug(f'MCP tool {name} executed successfully') return result except Exception as e: @@ -400,6 +1459,159 @@ class MCPLoader(loader.ToolLoader): raise ValueError(f'Tool not found: {name}') + async def get_resources(self, server_name: str) -> list[dict]: + """Get resources from a specific MCP server.""" + session = self.get_session(server_name) + if session is None: + raise ValueError(f'MCP server not found: {server_name}') + return session.get_resources() + + async def get_resource_templates(self, server_name: str) -> list[dict]: + """Get resource templates from a specific MCP server.""" + session = self.get_session(server_name) + if session is None: + raise ValueError(f'MCP server not found: {server_name}') + return session.get_resource_templates() + + async def read_resource_envelope( + self, + server_name: str, + uri: str, + *, + max_bytes: int = MCP_RESOURCE_PREVIEW_MAX_BYTES, + max_tokens: int | None = None, + include_blob: bool = False, + source: str = 'api', + query: pipeline_query.Query | None = None, + ) -> dict: + """Read a resource from a specific MCP server and return metadata plus contents.""" + session = self.get_session(server_name) + if session is None: + raise ValueError(f'MCP server not found: {server_name}') + return await session.read_resource_envelope( + uri, + max_bytes=max_bytes, + max_tokens=max_tokens, + include_blob=include_blob, + source=source, + query=query, + ) + + async def read_resource(self, server_name: str, uri: str) -> list[dict]: + """Read a resource from a specific MCP server.""" + envelope = await self.read_resource_envelope(server_name, uri) + return envelope['contents'] + + def get_session_by_uuid(self, server_uuid: str) -> RuntimeMCPSession | None: + for session in self.sessions.values(): + if session.server_uuid == server_uuid: + return session + return None + + def _resolve_attachment_session(self, attachment: dict) -> RuntimeMCPSession | None: + server_uuid = attachment.get('server_uuid') or attachment.get('server_id') + server_name = attachment.get('server_name') + if server_uuid: + return self.get_session_by_uuid(server_uuid) + if server_name: + return self.get_session(server_name) + return None + + async def build_resource_context_for_query( + self, + query: pipeline_query.Query, + *, + default_max_tokens: int = MCP_RESOURCE_CONTEXT_MAX_TOKENS, + default_max_bytes: int = MCP_RESOURCE_CONTEXT_MAX_BYTES, + ) -> str: + """Build host-controlled MCP resource context for the current query.""" + if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False: + return '' + + attachments = (query.variables or {}).get('_pipeline_mcp_resource_attachments', []) + if not isinstance(attachments, list) or not attachments: + return '' + + bound = self._get_bound_mcp_from_query(query) + eligible = self._eligible_resource_sessions_for_bound(bound) + eligible_by_uuid = {session.server_uuid: session for session in eligible} + eligible_by_name = {session.server_name: session for session in eligible} + + blocks: list[str] = [] + remaining_tokens = default_max_tokens + + for raw_attachment in attachments: + if remaining_tokens <= 0: + break + if not isinstance(raw_attachment, dict) or raw_attachment.get('enabled') is False: + continue + + attachment = raw_attachment.copy() + mode = attachment.get('mode', 'pinned') + if mode not in ('pinned', 'manual', 'auto'): + continue + + uri = attachment.get('uri') + if not uri or not isinstance(uri, str): + continue + + session = self._resolve_attachment_session(attachment) + if session is None: + continue + if session.server_uuid not in eligible_by_uuid and session.server_name not in eligible_by_name: + continue + + max_tokens = min(int(attachment.get('max_tokens') or remaining_tokens), remaining_tokens) + max_bytes = int(attachment.get('max_bytes') or default_max_bytes) + + try: + envelope = await session.read_resource_envelope( + uri, + max_bytes=max_bytes, + max_tokens=max_tokens, + include_blob=False, + source='preloaded', + query=query, + ) + except Exception as e: + self.ap.logger.warning(f'Failed to preload MCP resource {uri!r} from {session.server_name!r}: {e}') + continue + + for item in envelope.get('contents', []): + if item.get('type') != 'text': + continue + mime_type = item.get('mime_type', '') + text = item.get('text') or '' + if not text: + continue + approx_tokens = max(1, len(text) // 4) + remaining_tokens -= approx_tokens + header_attrs = { + 'server': session.server_name, + 'server_uuid': session.server_uuid, + 'uri': item.get('uri') or uri, + 'mime_type': mime_type, + 'bytes': item.get('bytes', 0), + 'truncated': item.get('truncated', False), + 'mode': mode, + } + attr_text = ' '.join(f'{k}={json.dumps(v, ensure_ascii=False)}' for k, v in header_attrs.items()) + blocks.append(f'\n{text}\n') + if remaining_tokens <= 0: + break + + context = '\n\n'.join(blocks) + if context: + try: + query.variables[MCP_RESOURCE_CONTEXT_QUERY_KEY] = { + 'resource_count': len(blocks), + 'max_tokens': default_max_tokens, + 'traces': query.variables.get(MCP_RESOURCE_TRACE_QUERY_KEY, []), + } + except Exception: + pass + return context + async def remove_mcp_server(self, server_name: str): """移除 MCP 服务器""" if server_name not in self.sessions: @@ -431,12 +1643,13 @@ class MCPLoader(loader.ToolLoader): """获取所有服务器的信息""" info = {} for server_name, session in self.sessions.items(): + tools = session.get_tools() info[server_name] = { 'name': server_name, 'mode': session.server_config.get('mode'), 'enable': session.enable, - 'tools_count': len(session.get_tools()), - 'tool_names': [f.name for f in session.get_tools()], + 'tools_count': len(tools), + 'tool_names': [f.name for f in tools], } return info diff --git a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py new file mode 100644 index 000000000..134c60a88 --- /dev/null +++ b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py @@ -0,0 +1,590 @@ +from __future__ import annotations + +import enum +import asyncio +import os +import shutil +import shlex +import threading +from contextlib import suppress, AsyncExitStack +from typing import TYPE_CHECKING, Any + +import pydantic +from mcp import ClientSession +from mcp.client.websocket import websocket_client +from ....box.workspace import ( + BoxWorkspaceSession, + classify_python_workspace, + infer_workspace_host_path, + normalize_host_path, + rewrite_mounted_path, + rewrite_venv_command, + unwrap_venv_path, + wrap_python_command_with_env, +) + +if TYPE_CHECKING: + from .mcp import RuntimeMCPSession + + +_WORKSPACE_COPY_LOCKS: dict[str, threading.Lock] = {} +_WORKSPACE_COPY_LOCKS_GUARD = threading.Lock() + + +def _workspace_copy_lock(path: str) -> threading.Lock: + with _WORKSPACE_COPY_LOCKS_GUARD: + lock = _WORKSPACE_COPY_LOCKS.get(path) + if lock is None: + lock = threading.Lock() + _WORKSPACE_COPY_LOCKS[path] = lock + return lock + + +class MCPSessionErrorPhase(enum.Enum): + """Which phase of the MCP lifecycle failed.""" + + SESSION_CREATE = 'session_create' + DEP_INSTALL = 'dep_install' + PROCESS_START = 'process_start' + RELAY_CONNECT = 'relay_connect' + MCP_INIT = 'mcp_init' + RUNTIME = 'runtime' + TOOL_CALL = 'tool_call' + # Stdio MCP refused because Box is disabled in config or currently + # unavailable. Not transient — retries would be pointless. The frontend + # uses this phase to render a localized actionable message instead of + # the raw RuntimeError text. + BOX_UNAVAILABLE = 'box_unavailable' + + +def _get_default_memory_mb(ap) -> int: + """Read box.default_memory_mb from instance config (env: BOX__DEFAULT_MEMORY_MB). + + Falls back to 1536 MB — a safe floor for Node.js V8 + WASM under nsjail. + Operators running memory-constrained hosts can lower this; those with large + machines can raise it. Individual MCP servers can still override via their + own box.memory_mb setting. + """ + try: + data = getattr(getattr(ap, 'instance_config', None), 'data', None) + if isinstance(data, dict): + return int(data.get('box', {}).get('default_memory_mb', 1536)) + except (TypeError, ValueError): + pass + return 1536 + + +class MCPServerBoxConfig(pydantic.BaseModel): + """Structured configuration for running an MCP server inside a Box container.""" + + image: str | None = None + network: str = 'on' # MCP servers need network for dependency installation + host_path: str | None = None + host_path_mode: str = 'ro' # MCP servers default to read-write mount only when explicitly requested + env: dict[str, str] = pydantic.Field(default_factory=dict) + startup_timeout_sec: int = 300 # First Docker bootstrap may need to build a venv and install MCP deps. + cpus: float | None = None + memory_mb: int | None = None + pids_limit: int | None = None + read_only_rootfs: bool | None = None + + model_config = pydantic.ConfigDict(extra='ignore') + + +_HANDSHAKE_ATTEMPT_TIMEOUT_SEC = 10.0 + + +class _TransferredStack: + """Adapts an already-populated AsyncExitStack into an async context manager + so ownership of its resources can be transferred into another exit stack. + Entering is a no-op; exiting closes the wrapped stack (and thus the live WS + transport + ClientSession) when the owning session shuts down.""" + + def __init__(self, stack: AsyncExitStack): + self._stack = stack + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + await self._stack.aclose() + return False + + +class _ColdStartRetry(Exception): + """Signal: the managed process is alive but not yet answering the MCP + handshake because it is still cold-starting (e.g. `npx -y ` is still + installing). The outer lifecycle retry treats this like a transient + reconnect: it reuses the live process and does not count toward the fatal + retry budget, so a slow cold start is waited out rather than failing. + """ + + +class BoxStdioSessionRuntime: + """Encapsulate Box-backed stdio MCP session orchestration.""" + + def __init__(self, owner: RuntimeMCPSession): + self.owner = owner + self.config = MCPServerBoxConfig.model_validate(owner.server_config.get('box', {})) + + @property + def ap(self): + return self.owner.ap + + @property + def server_name(self) -> str: + return self.owner.server_name + + @property + def server_config(self) -> dict: + return self.owner.server_config + + def _build_workspace( + self, + *, + host_path: str | None | object = ..., + workdir: str = '/workspace', + mount_path: str = '/workspace', + ) -> BoxWorkspaceSession: + resolved_host_path = self.resolve_host_path() if host_path is ... else host_path + return BoxWorkspaceSession( + self.ap.box_service, + self.owner._build_box_session_id(), + host_path=resolved_host_path, + host_path_mode=self.config.host_path_mode, + workdir=workdir, + env=self.config.env, + mount_path=mount_path, + network=self.config.network, + read_only_rootfs=self.config.read_only_rootfs if self.config.read_only_rootfs is not None else False, + image=self.config.image, + cpus=self.config.cpus, + # Node.js runtimes (npx/bunx) reserve large virtual address space and + # load WebAssembly modules (llhttp) on startup; the default 512 MB + # cgroup_mem_max is too small and causes OOM kills (return_code=137). + # Auto-bump to 1024 MB when the runner is npx/bunx/pnpm dlx. + # Per-server override wins; global default comes from + # config.yaml box.default_memory_mb (env: BOX__DEFAULT_MEMORY_MB). + # Hard floor of 1536 MB: enough for Node.js V8 + WASM without OOM. + # Per-server override wins; global default from config.yaml + # box.default_memory_mb (env: BOX__DEFAULT_MEMORY_MB), hard floor + # of 1536 MB so Node.js V8 + WASM never OOM under nsjail. + memory_mb=(self.config.memory_mb or _get_default_memory_mb(self.ap)), + pids_limit=self.config.pids_limit, + persistent=True, + ) + + @property + def process_id(self) -> str: + """Each MCP server gets a unique process_id within the shared session.""" + return self.owner.server_uuid + + def uses_box_stdio(self) -> bool: + if self.server_config.get('mode') != 'stdio': + return False + box_service = getattr(self.ap, 'box_service', None) + if box_service is None: + return False + # When Box is configured but currently unavailable (disabled or + # connection failed), do NOT silently fall through to host-stdio — + # that would bypass the sandbox the operator asked for. The caller + # is expected to refuse the stdio MCP server with a clear error. + return bool(getattr(box_service, 'available', False)) + + async def initialize(self) -> None: + await self._wait_for_box_runtime() + + # All stdio MCP servers share one Box session. Per-server host paths + # are staged into the shared workspace instead of becoming session + # mounts, because an existing Docker container cannot add bind mounts. + workspace = self._build_workspace(host_path=None) + host_path = self.resolve_host_path() + process_cwd = '/workspace' + install_cmd: str | None = None + + try: + await workspace.create_session() + except Exception: + self.owner.error_phase = MCPSessionErrorPhase.SESSION_CREATE + raise + + if host_path: + process_cwd = await self._stage_host_path_to_shared_workspace(host_path) + install_cmd = self.detect_install_command(host_path, process_cwd) + if install_cmd: + self.ap.logger.info( + f'MCP server {self.server_name}: installing dependencies in Box with: {install_cmd}' + ) + try: + result = await workspace.execute_raw( + install_cmd, + workdir=process_cwd, + timeout_sec=self.config.startup_timeout_sec or 120, + ) + except Exception: + self.owner.error_phase = MCPSessionErrorPhase.DEP_INSTALL + raise + if not result.ok: + self.owner.error_phase = MCPSessionErrorPhase.DEP_INSTALL + stderr_preview = (result.stderr or '')[:500] + raise Exception(f'Dependency install failed (exit code {result.exit_code}): {stderr_preview}') + + # Reuse an already-running managed process instead of rebuilding it. + # The Box runtime keeps the managed process alive across a transient + # WebSocket transport drop, so on a reconnect we only need to re-attach + # the WS below. Rebuilding here would needlessly stop a healthy process + # and re-run the (slow, network-touching) dependency bootstrap. + if not await self._managed_process_is_running(): + try: + process_workspace = ( + self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd) + if host_path + else workspace + ) + payload = process_workspace.build_process_payload( + self.server_config['command'], + self.server_config.get('args', []), + env=self.server_config.get('env', {}), + cwd=process_cwd, + ) + if install_cmd: + payload = self._wrap_process_payload_with_python_env(payload, process_cwd) + payload['process_id'] = self.process_id + await workspace.box_service.start_managed_process(workspace.session_id, payload) + except Exception: + self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START + raise + else: + self.ap.logger.info( + f'MCP server {self.server_name}: reusing live managed process ' + f'process_id={self.process_id} (transport reconnect)' + ) + + websocket_url = workspace.get_managed_process_websocket_url(self.process_id) + + # Attach the WS transport + MCP session ONCE, on the owner's exit stack, + # in the same task as the serve loop that follows. websocket_client and + # ClientSession use anyio task groups whose cancel scope is bound to the + # frame/stack that entered them, so they must live on the owner exit + # stack (not a deferred/transferred one) or the streams close the moment + # initialize() returns and the next request fails with "Connection + # closed". + # + # A slow (`npx -y `) cold start makes this single attempt fail + # while the process is still alive — the package is still installing and + # cannot answer the handshake. We surface that to the outer retry loop + # as a _ColdStartRetry: it must NOT stop the process (it is healthy and + # will be reused) and must NOT consume the fatal retry budget. The next + # attempt re-attaches to the same live process; once it has finished + # cold start the handshake succeeds and stays healthy. + try: + transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url)) + read_stream, write_stream = transport + self.owner.session = await self.owner.exit_stack.enter_async_context( + ClientSession(read_stream, write_stream) + ) + except Exception: + self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT + if not await self._managed_process_has_exited(): + # Process is alive but not yet serving (cold start) — reconnect. + raise _ColdStartRetry(f'{self.server_name}: transport not ready during cold start') + raise + + try: + await asyncio.wait_for(self.owner.session.initialize(), timeout=_HANDSHAKE_ATTEMPT_TIMEOUT_SEC) + except Exception as exc: + self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT + if not await self._managed_process_has_exited(): + raise _ColdStartRetry( + f'{self.server_name}: handshake not ready during cold start ({type(exc).__name__})' + ) + raise + + async def monitor_process_health(self) -> None: + from langbot_plugin.box.models import BoxManagedProcessStatus + + workspace = self._build_workspace() + consecutive_errors = 0 + while not self.owner._shutdown_event.is_set(): + try: + info = await workspace.get_managed_process(self.process_id) + if isinstance(info, dict): + status = info.get('status', '') + else: + status = getattr(info, 'status', '') + if status == BoxManagedProcessStatus.EXITED.value or status == BoxManagedProcessStatus.EXITED: + return + consecutive_errors = 0 + except Exception as exc: + consecutive_errors += 1 + self.ap.logger.warning( + f'MCP monitor for {self.server_name}: get_managed_process failed ' + f'({consecutive_errors}/{self.owner._MONITOR_MAX_CONSECUTIVE_ERRORS}): ' + f'{type(exc).__name__}: {exc}' + ) + if consecutive_errors >= self.owner._MONITOR_MAX_CONSECUTIVE_ERRORS: + return + + # Capture stderr logs from the managed process + if isinstance(info, dict): + stderr_text = info.get('stderr', '') or info.get('stderr_preview', '') + else: + stderr_text = getattr(info, 'stderr', '') or getattr(info, 'stderr_preview', '') + + if stderr_text and stderr_text != self.owner._last_stderr_text: + # Find new lines not in the previous snapshot + old_lines = set(self.owner._last_stderr_text.splitlines()) if self.owner._last_stderr_text else set() + new_lines = [l for l in stderr_text.splitlines() if l and l not in old_lines] + self.owner._last_stderr_text = stderr_text + + import time as _time + + for line in new_lines: + level = ( + 'error' + if any(k in line.upper() for k in ('ERROR', 'CRITICAL')) + else 'warning' + if 'WARNING' in line.upper() + else 'debug' + if 'DEBUG' in line.upper() + else 'info' + ) + self.owner._log_buffer.append({'ts': _time.time(), 'level': level, 'text': line}) + + await asyncio.sleep(self.owner._MONITOR_POLL_INTERVAL) + + async def _managed_process_is_running(self) -> bool: + """Return True if this server's managed process exists and is running. + + Used to decide whether initialize() must (re)start the process or can + simply re-attach the WebSocket transport to a process the Box runtime + kept alive across a transient transport drop. + """ + from langbot_plugin.box.models import BoxManagedProcessStatus + + workspace = self._build_workspace() + try: + info = await workspace.get_managed_process(self.process_id) + except Exception: + return False + status = info.get('status', '') if isinstance(info, dict) else getattr(info, 'status', '') + return status in (BoxManagedProcessStatus.RUNNING.value, BoxManagedProcessStatus.RUNNING) + + async def _managed_process_has_exited(self) -> bool: + """Return True only if the process is DEFINITIVELY gone (reports EXITED). + + Distinct from ``not _managed_process_is_running()``: a process that has + just been spawned may not yet report RUNNING, and a transient query + error is not proof of exit. During the cold-start handshake retry we + must NOT treat 'not yet running' or 'query failed' as a terminal + failure, or we bail out to the outer rebuild path and churn the + process (relay then rejects the early re-attach with HTTP 400). Only a + successful query that reports EXITED stops the retry loop. + """ + from langbot_plugin.box.models import BoxManagedProcessStatus + + workspace = self._build_workspace() + try: + info = await workspace.get_managed_process(self.process_id) + except Exception: + # Unknown — treat as 'still coming up', not exited. + return False + status = info.get('status', '') if isinstance(info, dict) else getattr(info, 'status', '') + return status in (BoxManagedProcessStatus.EXITED.value, BoxManagedProcessStatus.EXITED) + + async def _stage_host_path_to_shared_workspace(self, host_path: str) -> str: + source_path = normalize_host_path(host_path) + if not source_path: + return '/workspace' + if not os.path.isdir(source_path): + raise FileNotFoundError(f'MCP host_path does not exist or is not a directory: {host_path}') + + self._validate_host_path(source_path) + + shared_host_path = self._shared_workspace_host_path() + process_host_root = os.path.join(shared_host_path, '.mcp', self.process_id) + process_host_workspace = os.path.join(process_host_root, 'workspace') + await asyncio.to_thread(self._copy_workspace_tree, source_path, process_host_root, process_host_workspace) + return f'/workspace/.mcp/{self.process_id}/workspace' + + def _validate_host_path(self, host_path: str) -> None: + self.ap.box_service.build_spec( + { + 'session_id': f'mcp-validate-{self.process_id}', + 'host_path': host_path, + 'host_path_mode': self.config.host_path_mode, + 'network': self.config.network, + 'read_only_rootfs': self.config.read_only_rootfs if self.config.read_only_rootfs is not None else False, + } + ) + + def _shared_workspace_host_path(self) -> str: + default_workspace = getattr(self.ap.box_service, 'default_workspace', None) + if not default_workspace: + raise RuntimeError('Box default workspace is required for shared MCP host_path staging') + shared_host_path = normalize_host_path(default_workspace) + os.makedirs(shared_host_path, exist_ok=True) + return shared_host_path + + @staticmethod + def _copy_workspace_tree(source_path: str, process_host_root: str, process_host_workspace: str) -> None: + # Docker-backed bootstrap writes root-owned runtime directories such as + # .venv/.tmp into the staged workspace. The host process may not be able + # to delete them, so refresh source files in place and preserve runtime + # directories instead of rmtree'ing the whole staging root. + with _workspace_copy_lock(process_host_root): + preserved_names = {'.venv', 'venv', 'env', '.cache', '.tmp', '.langbot'} + os.makedirs(process_host_workspace, exist_ok=True) + for name in os.listdir(process_host_workspace): + if name in preserved_names: + continue + path = os.path.join(process_host_workspace, name) + if os.path.isdir(path) and not os.path.islink(path): + shutil.rmtree(path, ignore_errors=True) + else: + # The entry may disappear between listdir and unlink if cleanup races us. + with suppress(FileNotFoundError): + os.unlink(path) + shutil.copytree( + source_path, + process_host_workspace, + symlinks=True, + dirs_exist_ok=True, + ignore=shutil.ignore_patterns( + '.git', + '__pycache__', + '.pytest_cache', + '.mypy_cache', + '.ruff_cache', + '.venv', + 'venv', + 'env', + '.cache', + '.tmp', + '.langbot', + ), + ) + + async def _cleanup_staged_workspace(self) -> None: + if not self.resolve_host_path(): + return + try: + process_host_root = os.path.join(self._shared_workspace_host_path(), '.mcp', self.process_id) + await asyncio.to_thread(shutil.rmtree, process_host_root, True) + except Exception as exc: + self.ap.logger.warning( + f'MCP server {self.server_name}: failed to clean staged workspace ' + f'process_id={self.process_id}: {type(exc).__name__}: {exc}' + ) + + async def _wait_for_box_runtime(self) -> None: + timeout_sec = max(float(self.config.startup_timeout_sec or 120), 1.0) + deadline = asyncio.get_running_loop().time() + timeout_sec + warned = False + while not getattr(self.ap.box_service, 'available', False): + if not warned: + self.ap.logger.warning( + f'MCP server {self.server_name}: waiting for Box runtime before starting stdio process' + ) + warned = True + if asyncio.get_running_loop().time() >= deadline: + self.owner.error_phase = MCPSessionErrorPhase.SESSION_CREATE + raise Exception(f'Box runtime is not available after {int(timeout_sec)} seconds') + await asyncio.sleep(1) + + async def cleanup_session(self) -> None: + if not self.uses_box_stdio(): + return + + workspace = self._build_workspace(host_path=None) + + # Transient config-page tests now share the same 'mcp-shared' Box + # session as live servers, so we must NOT tear the session down here — + # that would kill every other MCP server in the container. A test is + # isolated at the process level: it ran under its own process_id, so we + # stop only that process, exactly like a live server does below. The + # shared session and all other servers' live processes are untouched. + # (Staged per-test workspace files are still cleaned up.) + if getattr(self.owner, 'is_transient', False): + try: + await workspace.stop_managed_process(self.process_id) + except Exception as exc: + self.ap.logger.warning( + f'MCP server {self.server_name}: failed to stop transient test process ' + f'process_id={self.process_id}: {type(exc).__name__}: {exc}' + ) + await self._cleanup_staged_workspace() + return + + # In the shared-session model, we do not delete the session itself. + # Stop only this MCP server's managed process; deleting the session + # would kill other MCP servers sharing the same container. + try: + await workspace.stop_managed_process(self.process_id) + except Exception as exc: + self.ap.logger.warning( + f'MCP server {self.server_name}: failed to stop managed process ' + f'process_id={self.process_id}: {type(exc).__name__}: {exc}' + ) + await self._cleanup_staged_workspace() + return + await self._cleanup_staged_workspace() + self.ap.logger.info( + f'MCP server {self.server_name}: stopped process_id={self.process_id} ' + f'(shared session {self.owner._build_box_session_id()} kept alive)' + ) + + def rewrite_path(self, path: str, host_path: str | None) -> str: + return rewrite_mounted_path(path, host_path) + + def infer_host_path(self) -> str | None: + return infer_workspace_host_path(self.server_config.get('command', ''), self.server_config.get('args', [])) + + @staticmethod + def unwrap_venv_path(directory: str) -> str: + return unwrap_venv_path(directory) + + def resolve_host_path(self) -> str | None: + return self.config.host_path or self.infer_host_path() + + @staticmethod + def detect_install_command(host_path: str, workspace_path: str = '/workspace') -> str | None: + workspace_kind = classify_python_workspace(host_path) + if workspace_kind in {'package', 'requirements'}: + return wrap_python_command_with_env('python -c "pass"', mount_path=workspace_path).rstrip() + return None + + @staticmethod + def _wrap_process_payload_with_python_env(payload: dict[str, Any], workspace_path: str) -> dict[str, Any]: + """Start a prepared Python workspace without writing bootstrap output to MCP stdio.""" + workspace_root = workspace_path.rstrip('/') or '/workspace' + venv_dir = f'{workspace_root}/.venv' + venv_bin = f'{venv_dir}/bin' + command = ' '.join([shlex.quote(payload['command']), *[shlex.quote(arg) for arg in payload.get('args', [])]]) + wrapped = dict(payload) + wrapped['command'] = 'sh' + wrapped['args'] = [ + '-lc', + (f'export VIRTUAL_ENV={shlex.quote(venv_dir)}; export PATH={shlex.quote(venv_bin)}:$PATH; exec {command}'), + ] + return wrapped + + def build_box_session_payload(self, session_id: str, host_path: str | None = None) -> dict[str, Any]: + workspace = self._build_workspace() + workspace.session_id = session_id + if host_path is not None: + workspace.host_path = host_path + return workspace.build_session_payload() + + def build_box_process_payload(self, host_path: str | None = None) -> dict[str, Any]: + workspace = self._build_workspace() + if host_path is not None: + workspace.host_path = host_path + return workspace.build_process_payload( + self.server_config['command'], + self.server_config.get('args', []), + env=self.server_config.get('env', {}), + ) + + def rewrite_venv_command(self, command: str, host_path: str) -> str: + return rewrite_venv_command(command, host_path) diff --git a/src/langbot/pkg/provider/tools/loaders/native.py b/src/langbot/pkg/provider/tools/loaders/native.py new file mode 100644 index 000000000..7f5ee4226 --- /dev/null +++ b/src/langbot/pkg/provider/tools/loaders/native.py @@ -0,0 +1,1308 @@ +from __future__ import annotations + +import base64 +import json +import os + +import langbot_plugin.api.entities.builtin.resource.tool as resource_tool +from langbot_plugin.api.entities.events import pipeline_query + +from .. import loader +from ..errors import ToolNotFoundError +from .availability import is_box_backend_available +from . import skill as skill_loader + +EXEC_TOOL_NAME = 'exec' +READ_TOOL_NAME = 'read' +WRITE_TOOL_NAME = 'write' +EDIT_TOOL_NAME = 'edit' +GLOB_TOOL_NAME = 'glob' +GREP_TOOL_NAME = 'grep' + +_ALL_TOOL_NAMES = {EXEC_TOOL_NAME, READ_TOOL_NAME, WRITE_TOOL_NAME, EDIT_TOOL_NAME, GLOB_TOOL_NAME, GREP_TOOL_NAME} + +# Skip these dirs during grep walk to avoid noise +_SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', '.tox', 'dist', 'build'} + +_DEFAULT_READ_MAX_LINES = 2000 +_MAX_READ_MAX_LINES = 10000 +_DEFAULT_TOOL_RESULT_MAX_BYTES = 50 * 1024 +_BOX_FILE_SCRIPT_MAX_BYTES = 2048 +_GLOB_MAX_MATCHES = 100 +_GREP_MAX_MATCHES = 200 +_GREP_MAX_FILES = 5000 +_GREP_MAX_LINE_CHARS = 500 + + +class NativeToolLoader(loader.ToolLoader): + def __init__(self, ap): + super().__init__(ap) + self._tools: list[resource_tool.LLMTool] | None = None + self._backend_available: bool | None = None + + async def initialize(self): + """Check if backend is truly available at startup.""" + self._backend_available = await self._check_backend_available() + if self._backend_available: + self.ap.logger.info('Native sandbox tools (exec/read/write/edit/glob/grep) are available.') + else: + self.ap.logger.warning( + 'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. ' + 'No sandbox backend (Docker/nsjail/E2B) is ready. ' + 'The LLM will not have access to code execution or file operation tools.' + ) + + async def _check_backend_available(self) -> bool: + """Check if the box backend is truly available (not just the runtime).""" + return await is_box_backend_available(self.ap) + + async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]: + if not self._is_sandbox_available(): + return [] + if self._tools is None: + self._tools = [ + self._build_exec_tool(), + self._build_read_tool(), + self._build_write_tool(), + self._build_edit_tool(), + self._build_glob_tool(), + self._build_grep_tool(), + ] + return list(self._tools) + + async def has_tool(self, name: str) -> bool: + return name in _ALL_TOOL_NAMES and self._is_sandbox_available() + + async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query): + if name == EXEC_TOOL_NAME: + self.ap.logger.info( + 'exec tool invoked: ' + f'query_id={query.query_id} ' + f'parameters={json.dumps(self._summarize_parameters(parameters), ensure_ascii=False)}' + ) + return await self._invoke_exec(parameters, query) + if name == READ_TOOL_NAME: + return await self._invoke_read(parameters, query) + if name == WRITE_TOOL_NAME: + return await self._invoke_write(parameters, query) + if name == EDIT_TOOL_NAME: + return await self._invoke_edit(parameters, query) + if name == GLOB_TOOL_NAME: + return await self._invoke_glob(parameters, query) + if name == GREP_TOOL_NAME: + return await self._invoke_grep(parameters, query) + raise ToolNotFoundError(name) + + async def shutdown(self): + pass + + async def _invoke_exec(self, parameters: dict, query: pipeline_query.Query) -> dict: + command = str(parameters['command']) + workdir = str(parameters.get('workdir', '/workspace') or '/workspace') + + # Validate that skill references target activated skills. + selected_skill, _ = skill_loader.resolve_virtual_skill_path( + self.ap, + query, + workdir, + include_visible=False, + include_activated=True, + ) + referenced_skill_names = skill_loader.find_referenced_skill_names(command) + + if selected_skill is None and referenced_skill_names: + if len(referenced_skill_names) > 1: + raise ValueError('exec can target at most one activated skill package per call.') + selected_skill = skill_loader.get_activated_skill(query, referenced_skill_names[0]) + if selected_skill is None: + raise ValueError( + f'Skill "{referenced_skill_names[0]}" must be activated before exec can run in its package.' + ) + + if selected_skill is not None: + selected_skill_name = str(selected_skill.get('name', '') or '') + if referenced_skill_names and any(name != selected_skill_name for name in referenced_skill_names): + raise ValueError('exec can reference files from only one activated skill package per call.') + + package_root = str(selected_skill.get('package_root', '') or '').strip() + if not package_root: + raise ValueError(f'Activated skill "{selected_skill_name}" has no package_root.') + + # Wrap command with Python venv bootstrap if the skill has a Python project. + # The venv is created inside the skill's mount path. + skill_mount = f'/workspace/.skills/{selected_skill_name}' + if skill_loader.should_prepare_skill_python_env(package_root): + parameters = dict(parameters) + parameters['command'] = skill_loader.wrap_skill_command_with_python_env(command, mount_path=skill_mount) + + # All exec calls (with or without skills) go through the same container + # via execute_tool. Skills are mounted at /workspace/.skills/{name}/ + # via extra_mounts built by BoxService. + result = await self.ap.box_service.execute_tool(parameters, query) + result = self._normalize_exec_result(result) + + if selected_skill is not None: + self._refresh_skill_from_disk(selected_skill) + return result + + def _resolve_host_path( + self, + query: pipeline_query.Query, + sandbox_path: str, + *, + include_visible: bool, + include_activated: bool, + ) -> tuple[str, dict | None]: + selected_skill, rewritten_path = skill_loader.resolve_virtual_skill_path( + self.ap, + query, + sandbox_path, + include_visible=include_visible, + include_activated=include_activated, + ) + + box_service = self.ap.box_service + host_root = selected_skill.get('package_root') if selected_skill is not None else box_service.default_workspace + if not host_root: + raise ValueError('No host workspace configured for file operations.') + + mount_path = '/workspace' + if not rewritten_path.startswith(mount_path): + raise ValueError(f'Path must be under {mount_path}.') + + relative = rewritten_path[len(mount_path) :].lstrip('/') + host_path = os.path.realpath(os.path.join(host_root, relative)) + host_root = os.path.realpath(host_root) + + if not (host_path == host_root or host_path.startswith(host_root + os.sep)): + raise ValueError('Path escapes the workspace boundary.') + + return host_path, selected_skill + + def _resolve_skill_relative_path( + self, + query: pipeline_query.Query, + sandbox_path: str, + *, + include_visible: bool, + include_activated: bool, + ) -> tuple[dict, str] | None: + selected_skill, rewritten_path = skill_loader.resolve_virtual_skill_path( + self.ap, + query, + sandbox_path, + include_visible=include_visible, + include_activated=include_activated, + ) + if selected_skill is None: + return None + + mount_path = '/workspace' + if not rewritten_path.startswith(mount_path): + raise ValueError(f'Path must be under {mount_path}.') + relative = rewritten_path[len(mount_path) :].lstrip('/') or '.' + return selected_skill, relative + + def _should_use_box_workspace_files(self, selected_skill: dict | None) -> bool: + if selected_skill is not None: + return False + box_service = getattr(self.ap, 'box_service', None) + if box_service is None or not hasattr(box_service, 'execute_tool'): + return False + default_workspace = getattr(box_service, 'default_workspace', None) + return bool(default_workspace and not os.path.isdir(os.path.realpath(default_workspace))) + + async def _run_workspace_file_script(self, script: str, query: pipeline_query.Query) -> dict: + result = await self.ap.box_service.execute_tool( + { + 'command': f"python - <<'PY'\n{script}\nPY", + 'timeout_sec': 30, + }, + query, + ) + if not result.get('ok'): + return {'ok': False, 'error': result.get('stderr') or result.get('stdout') or 'Box execution failed'} + stdout = str(result.get('stdout') or '').strip() + try: + return json.loads(stdout.splitlines()[-1]) + except Exception: + return {'ok': False, 'error': stdout or 'Box file operation returned no result'} + + async def _read_workspace_via_box(self, path: str, parameters: dict, query: pipeline_query.Query) -> dict: + offset = self._positive_int(parameters.get('offset'), default=1) + byte_offset = self._non_negative_int(parameters.get('byte_offset'), default=0) + max_lines = self._positive_int( + parameters.get('limit'), + default=_DEFAULT_READ_MAX_LINES, + max_value=_MAX_READ_MAX_LINES, + ) + # Box file fallback returns through exec stdout, which is already capped + # by BoxService. Keep this payload small enough to remain valid JSON. + max_bytes = min( + self._positive_int(parameters.get('max_bytes'), default=_DEFAULT_TOOL_RESULT_MAX_BYTES), + _BOX_FILE_SCRIPT_MAX_BYTES, + ) + encoding = self._read_encoding(parameters) + script = f""" +import base64, json, os +path = {json.dumps(path)} +offset = {offset} +byte_offset = {byte_offset} +max_lines = {max_lines} +max_bytes = {max_bytes} +encoding = {json.dumps(encoding)} +if not path.startswith('/workspace'): + print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}})) +elif not os.path.exists(path): + print(json.dumps({{'ok': False, 'error': f'File not found: {{path}}'}})) +elif os.path.isdir(path): + entries = sorted(os.listdir(path)) + content = '\\n'.join(entries) + print(json.dumps({{'ok': True, 'content': content, 'is_directory': True, 'total': len(entries), 'truncated': False}})) +elif encoding == 'base64': + size_bytes = os.path.getsize(path) + with open(path, 'rb') as f: + f.seek(byte_offset) + data = f.read(max_bytes + 1) + chunk = data[:max_bytes] + has_more = len(data) > max_bytes + print(json.dumps({{ + 'ok': True, + 'content': base64.b64encode(chunk).decode('ascii'), + 'encoding': 'base64', + 'byte_offset': byte_offset, + 'length': len(chunk), + 'size_bytes': size_bytes, + 'has_more': has_more, + 'next_byte_offset': byte_offset + len(chunk) if has_more else None, + 'max_bytes': max_bytes, + }})) +else: + lines = [] + output_bytes = 0 + end_line = offset - 1 + truncated = False + next_offset = None + with open(path, 'r', encoding='utf-8', errors='replace') as f: + for line_number, line in enumerate(f, 1): + if line_number < offset: + continue + if len(lines) >= max_lines: + truncated = True + next_offset = line_number + break + line_bytes = len(line.encode('utf-8')) + if output_bytes + line_bytes > max_bytes: + truncated = True + next_offset = line_number + break + lines.append(line.rstrip('\\n')) + output_bytes += line_bytes + end_line = line_number + print(json.dumps({{ + 'ok': True, + 'content': '\\n'.join(lines), + 'truncated': truncated, + 'start_line': offset, + 'end_line': end_line, + 'next_offset': next_offset, + 'max_lines': max_lines, + 'max_bytes': max_bytes, + }})) +""".strip() + return await self._run_workspace_file_script(script, query) + + async def _write_workspace_via_box( + self, + path: str, + content: str, + parameters: dict, + query: pipeline_query.Query, + ) -> dict: + encoding, mode = self._write_options(parameters) + script = f""" +import base64, json, os +path = {json.dumps(path)} +content = {json.dumps(content)} +encoding = {json.dumps(encoding)} +mode = {json.dumps(mode)} +if not path.startswith('/workspace'): + print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}})) +else: + os.makedirs(os.path.dirname(path) or '/workspace', exist_ok=True) + if encoding == 'base64': + try: + data = base64.b64decode(content, validate=True) + except Exception as exc: + print(json.dumps({{'ok': False, 'error': f'invalid base64 content: {{exc}}'}})) + else: + with open(path, 'ab' if mode == 'append' else 'wb') as f: + f.write(data) + print(json.dumps({{'ok': True, 'path': path}})) + else: + with open(path, 'a' if mode == 'append' else 'w', encoding='utf-8') as f: + f.write(content) + print(json.dumps({{'ok': True, 'path': path}})) +""".strip() + return await self._run_workspace_file_script(script, query) + + async def _edit_workspace_via_box( + self, + path: str, + old_string: str, + new_string: str, + query: pipeline_query.Query, + ) -> dict: + script = f""" +import json, os +path = {json.dumps(path)} +old_string = {json.dumps(old_string)} +new_string = {json.dumps(new_string)} +if not path.startswith('/workspace'): + print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}})) +elif not os.path.isfile(path): + print(json.dumps({{'ok': False, 'error': f'File not found: {{path}}'}})) +else: + with open(path, 'r', encoding='utf-8', errors='replace') as f: + content = f.read() + count = content.count(old_string) + if count == 0: + print(json.dumps({{'ok': False, 'error': 'old_string not found in file.'}})) + elif count > 1: + print(json.dumps({{'ok': False, 'error': f'old_string matches {{count}} locations; provide a more unique string.'}})) + else: + with open(path, 'w', encoding='utf-8') as f: + f.write(content.replace(old_string, new_string, 1)) + print(json.dumps({{'ok': True, 'path': path}})) +""".strip() + return await self._run_workspace_file_script(script, query) + + async def _glob_workspace_via_box(self, path: str, pattern: str, query: pipeline_query.Query) -> dict: + script = f""" +import json, os +from pathlib import Path +path = {json.dumps(path)} +pattern = {json.dumps(pattern)} +skip_dirs = {json.dumps(sorted(_SKIP_DIRS))} +if not path.startswith('/workspace'): + print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}})) +elif not os.path.isdir(path): + print(json.dumps({{'ok': False, 'error': f'Path is not a directory: {{path}}'}})) +else: + base = Path(path) + hits = [ + item for item in base.rglob(pattern) + if not any(part in skip_dirs for part in item.parts) + ] + hits.sort(key=lambda item: item.stat().st_mtime if item.exists() else 0, reverse=True) + shown = hits[:{_GLOB_MAX_MATCHES}] + matches = [] + output_bytes = 0 + truncated_by_bytes = False + for item in shown: + rel = os.path.relpath(str(item), path) + sandbox_path = os.path.join(path, rel).replace(os.sep, '/') + entry_bytes = len(sandbox_path.encode('utf-8')) + (1 if matches else 0) + if output_bytes + entry_bytes > {_DEFAULT_TOOL_RESULT_MAX_BYTES}: + truncated_by_bytes = True + break + matches.append(sandbox_path) + output_bytes += entry_bytes + print(json.dumps({{ + 'ok': True, + 'matches': matches, + 'preview': '\\n'.join(matches), + 'total': len(hits), + 'truncated': len(hits) > len(matches) or truncated_by_bytes, + 'truncated_by': 'bytes' if truncated_by_bytes else ('matches' if len(hits) > len(matches) else None), + }})) +""".strip() + return await self._run_workspace_file_script(script, query) + + async def _grep_workspace_via_box( + self, + path: str, + pattern: str, + include: str | None, + query: pipeline_query.Query, + ) -> dict: + script = f""" +import json, os, re +from pathlib import Path +path = {json.dumps(path)} +pattern = {json.dumps(pattern)} +include = {json.dumps(include)} +skip_dirs = {json.dumps(sorted(_SKIP_DIRS))} +try: + regex = re.compile(pattern) +except re.error as exc: + print(json.dumps({{'ok': False, 'error': f'Invalid regex: {{exc}}'}})) +else: + if not path.startswith('/workspace'): + print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}})) + elif not os.path.exists(path): + print(json.dumps({{'ok': False, 'error': f'Path not found: {{path}}'}})) + else: + base = Path(path) + if base.is_file(): + files = [base] + else: + files = [] + for item in base.rglob(include or '*'): + if any(part in skip_dirs for part in item.parts): + continue + if item.is_file(): + files.append(item) + if len(files) >= {_GREP_MAX_FILES}: + break + + matches = [] + output_bytes = 0 + truncated_by = None + for fp in files: + try: + handle = fp.open('r', encoding='utf-8', errors='ignore') + except OSError: + continue + with handle: + for lineno, line in enumerate(handle, 1): + if regex.search(line): + if base.is_file(): + file_path = path + else: + rel = os.path.relpath(str(fp), path) + file_path = os.path.join(path, rel).replace(os.sep, '/') + content = line.rstrip() + line_truncated = False + if len(content) > {_GREP_MAX_LINE_CHARS}: + content = content[:{_GREP_MAX_LINE_CHARS}] + '... [truncated]' + line_truncated = True + entry = {{'file': file_path, 'line': lineno, 'content': content}} + entry_bytes = len(json.dumps(entry, ensure_ascii=False).encode('utf-8')) + 1 + if output_bytes + entry_bytes > {_DEFAULT_TOOL_RESULT_MAX_BYTES}: + truncated_by = 'bytes' + break + if line_truncated and truncated_by is None: + truncated_by = 'line' + matches.append(entry) + output_bytes += entry_bytes + if len(matches) >= {_GREP_MAX_MATCHES}: + truncated_by = truncated_by or 'matches' + break + if truncated_by == 'bytes' or len(matches) >= {_GREP_MAX_MATCHES}: + break + if truncated_by == 'bytes' or len(matches) >= {_GREP_MAX_MATCHES}: + break + + print(json.dumps({{ + 'ok': True, + 'matches': matches, + 'total': len(matches), + 'truncated': truncated_by is not None, + 'truncated_by': truncated_by, + }})) +""".strip() + return await self._run_workspace_file_script(script, query) + + async def _invoke_read(self, parameters: dict, query: pipeline_query.Query) -> dict: + path = parameters['path'] + self.ap.logger.info(f'read tool invoked: query_id={query.query_id} path={path}') + skill_request = self._resolve_skill_relative_path( + query, + path, + include_visible=True, + include_activated=True, + ) + if skill_request is not None and hasattr(self.ap.box_service, 'read_skill_file'): + selected_skill, relative = skill_request + host_path = self._resolve_skill_host_path(selected_skill, relative) + if host_path and os.path.exists(host_path): + if os.path.isdir(host_path): + return self._build_directory_result(os.listdir(host_path)) + return self._read_text_file_preview(host_path, parameters) + + try: + result = await self.ap.box_service.read_skill_file(selected_skill['name'], relative) + return self._build_read_result_from_text(str(result.get('content', '')), parameters) + except Exception: + try: + result = await self.ap.box_service.list_skill_files(selected_skill['name'], relative) + entries = [entry['name'] for entry in result.get('entries', [])] + return self._build_directory_result(entries) + except Exception as exc: + return {'ok': False, 'error': str(exc)} + + host_path, selected_skill = self._resolve_host_path( + query, + path, + include_visible=True, + include_activated=True, + ) + if self._should_use_box_workspace_files(selected_skill): + return await self._read_workspace_via_box(path, parameters, query) + if not os.path.exists(host_path): + return {'ok': False, 'error': f'File not found: {path}'} + if os.path.isdir(host_path): + entries = os.listdir(host_path) + return self._build_directory_result(entries) + return self._read_text_file_preview(host_path, parameters) + + async def _invoke_write(self, parameters: dict, query: pipeline_query.Query) -> dict: + path = parameters['path'] + content = parameters['content'] + self.ap.logger.info(f'write tool invoked: query_id={query.query_id} path={path} length={len(content)}') + encoding, _mode = self._write_options(parameters) + skill_request = self._resolve_skill_relative_path( + query, + path, + include_visible=False, + include_activated=True, + ) + if skill_request is not None and hasattr(self.ap.box_service, 'write_skill_file'): + if encoding != 'text': + return {'ok': False, 'error': 'base64 writes to skill packages are not supported.'} + selected_skill, relative = skill_request + await self.ap.box_service.write_skill_file(selected_skill['name'], relative, content) + await self.ap.skill_mgr.reload_skills() + return {'ok': True, 'path': path} + + host_path, selected_skill = self._resolve_host_path( + query, + path, + include_visible=False, + include_activated=True, + ) + if self._should_use_box_workspace_files(selected_skill): + return await self._write_workspace_via_box(path, content, parameters, query) + os.makedirs(os.path.dirname(host_path), exist_ok=True) + try: + self._write_host_file(host_path, content, parameters) + except ValueError as exc: + return {'ok': False, 'error': str(exc)} + self._refresh_skill_from_disk(selected_skill) + return {'ok': True, 'path': path} + + async def _invoke_edit(self, parameters: dict, query: pipeline_query.Query) -> dict: + path = parameters['path'] + old_string = parameters['old_string'] + new_string = parameters['new_string'] + self.ap.logger.info( + f'edit tool invoked: query_id={query.query_id} path={path} ' + f'old_len={len(old_string)} new_len={len(new_string)}' + ) + skill_request = self._resolve_skill_relative_path( + query, + path, + include_visible=False, + include_activated=True, + ) + if ( + skill_request is not None + and hasattr(self.ap.box_service, 'read_skill_file') + and hasattr(self.ap.box_service, 'write_skill_file') + ): + selected_skill, relative = skill_request + try: + result = await self.ap.box_service.read_skill_file(selected_skill['name'], relative) + except Exception: + return {'ok': False, 'error': f'File not found: {path}'} + content = result.get('content', '') + count = content.count(old_string) + if count == 0: + return {'ok': False, 'error': 'old_string not found in file.'} + if count > 1: + return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'} + new_content = content.replace(old_string, new_string, 1) + await self.ap.box_service.write_skill_file(selected_skill['name'], relative, new_content) + await self.ap.skill_mgr.reload_skills() + return {'ok': True, 'path': path} + + host_path, selected_skill = self._resolve_host_path( + query, + path, + include_visible=False, + include_activated=True, + ) + if self._should_use_box_workspace_files(selected_skill): + return await self._edit_workspace_via_box(path, old_string, new_string, query) + if not os.path.isfile(host_path): + return {'ok': False, 'error': f'File not found: {path}'} + with open(host_path, 'r', encoding='utf-8', errors='replace') as f: + content = f.read() + count = content.count(old_string) + if count == 0: + return {'ok': False, 'error': 'old_string not found in file.'} + if count > 1: + return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'} + new_content = content.replace(old_string, new_string, 1) + with open(host_path, 'w', encoding='utf-8') as f: + f.write(new_content) + self._refresh_skill_from_disk(selected_skill) + return {'ok': True, 'path': path} + + def _refresh_skill_from_disk(self, selected_skill: dict | None) -> None: + if selected_skill is None: + return + + skill_mgr = getattr(self.ap, 'skill_mgr', None) + if skill_mgr is None: + return + + refresh_skill = getattr(skill_mgr, 'refresh_skill_from_disk', None) + if callable(refresh_skill): + refresh_skill(selected_skill.get('name', '')) + + def _is_sandbox_available(self) -> bool: + """Check if sandbox backend is available. + + This checks the cached backend availability from initialization, + not just whether the box_service process is running. + """ + return bool(self._backend_available) + + def _build_exec_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=EXEC_TOOL_NAME, + human_desc='Execute a command in an isolated environment', + description=( + 'Run shell commands in an isolated execution environment. ' + 'Use this tool for bash commands, Python execution, and exact calculations over ' + 'user-provided data. Activated skill packages are addressable under ' + '/workspace/.skills/; when running inside one, set workdir to that path. ' + 'To create a new skill package, prepare it under /workspace first, then use register_skill.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'command': { + 'type': 'string', + 'description': 'Shell command to execute.', + }, + 'workdir': { + 'type': 'string', + 'description': 'Working directory for the command. Defaults to /workspace.', + 'default': '/workspace', + }, + 'timeout_sec': { + 'type': 'integer', + 'description': 'Execution timeout in seconds. Defaults to 30.', + 'default': 30, + 'minimum': 1, + }, + 'env': { + 'type': 'object', + 'description': 'Optional environment variables for the execution.', + 'additionalProperties': {'type': 'string'}, + 'default': {}, + }, + 'description': { + 'type': 'string', + 'description': 'Brief description of what this command does, for logging and audit.', + }, + }, + 'required': ['command'], + 'additionalProperties': False, + }, + func=lambda parameters: parameters, + ) + + def _build_read_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=READ_TOOL_NAME, + human_desc='Read a file from the workspace', + description=( + 'Read the contents of a file at the given path under /workspace. ' + 'Visible skill packages can be inspected through /workspace/.skills//... .' + ), + parameters={ + 'type': 'object', + 'properties': { + 'path': { + 'type': 'string', + 'description': 'Absolute path to the file (must be under /workspace).', + }, + 'offset': { + 'type': 'integer', + 'description': '1-indexed line number to start reading from. Defaults to 1.', + 'default': 1, + 'minimum': 1, + }, + 'limit': { + 'type': 'integer', + 'description': f'Maximum number of lines to return. Defaults to {_DEFAULT_READ_MAX_LINES}.', + 'default': _DEFAULT_READ_MAX_LINES, + 'minimum': 1, + 'maximum': _MAX_READ_MAX_LINES, + }, + 'max_bytes': { + 'type': 'integer', + 'description': ( + f'Maximum bytes of file content to return. Defaults to {_DEFAULT_TOOL_RESULT_MAX_BYTES}.' + ), + 'default': _DEFAULT_TOOL_RESULT_MAX_BYTES, + 'minimum': 1, + 'maximum': _DEFAULT_TOOL_RESULT_MAX_BYTES, + }, + 'encoding': { + 'type': 'string', + 'description': 'Return text by default, or base64 for binary byte-range reads.', + 'enum': ['text', 'base64'], + 'default': 'text', + }, + 'byte_offset': { + 'type': 'integer', + 'description': '0-indexed byte offset used when encoding is base64. Defaults to 0.', + 'default': 0, + 'minimum': 0, + }, + }, + 'required': ['path'], + 'additionalProperties': False, + }, + func=lambda parameters: parameters, + ) + + def _build_write_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=WRITE_TOOL_NAME, + human_desc='Write a file to the workspace', + description=( + 'Create or overwrite a file at the given path under /workspace with the provided content. ' + 'Activated skill packages can be modified through /workspace/.skills//... . ' + 'For new skills, write files under /workspace and then call register_skill.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'path': { + 'type': 'string', + 'description': 'Absolute path to the file (must be under /workspace).', + }, + 'content': { + 'type': 'string', + 'description': 'Text content, or base64 content when encoding is base64.', + }, + 'encoding': { + 'type': 'string', + 'description': 'Write content as text by default, or decode it from base64 for binary files.', + 'enum': ['text', 'base64'], + 'default': 'text', + }, + 'mode': { + 'type': 'string', + 'description': 'Overwrite the file by default, or append to it.', + 'enum': ['overwrite', 'append'], + 'default': 'overwrite', + }, + }, + 'required': ['path', 'content'], + 'additionalProperties': False, + }, + func=lambda parameters: parameters, + ) + + def _build_edit_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=EDIT_TOOL_NAME, + human_desc='Edit a file in the workspace', + description=( + 'Perform an exact string replacement in a file under /workspace. ' + 'The old_string must appear exactly once in the file. Activated skill packages ' + 'can be edited through /workspace/.skills//... . ' + 'For new skills, edit files under /workspace and then call register_skill.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'path': { + 'type': 'string', + 'description': 'Absolute path to the file (must be under /workspace).', + }, + 'old_string': { + 'type': 'string', + 'description': 'The exact string to find and replace.', + }, + 'new_string': { + 'type': 'string', + 'description': 'The replacement string.', + }, + }, + 'required': ['path', 'old_string', 'new_string'], + 'additionalProperties': False, + }, + func=lambda parameters: parameters, + ) + + def _build_glob_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=GLOB_TOOL_NAME, + human_desc='Find files matching a glob pattern', + description=( + 'Find files matching a glob pattern under /workspace. ' + 'Supports ** for recursive matching (e.g. **/*.py). ' + 'Results are sorted by modification time (newest first). ' + 'Visible and activated skill packages can be searched through /workspace/.skills//...' + ), + parameters={ + 'type': 'object', + 'properties': { + 'pattern': { + 'type': 'string', + 'description': 'Glob pattern, e.g. **/*.py or src/**/*.ts', + }, + 'path': { + 'type': 'string', + 'description': 'Directory to search in (must be under /workspace, default: /workspace)', + 'default': '/workspace', + }, + }, + 'required': ['pattern'], + 'additionalProperties': False, + }, + func=lambda parameters: parameters, + ) + + def _build_grep_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=GREP_TOOL_NAME, + human_desc='Search file contents with regex', + description=( + 'Search file contents with regex pattern under /workspace. ' + 'Returns matching lines with file path and line number. ' + 'Visible and activated skill packages can be searched through /workspace/.skills//...' + ), + parameters={ + 'type': 'object', + 'properties': { + 'pattern': { + 'type': 'string', + 'description': 'Regex pattern to search for', + }, + 'path': { + 'type': 'string', + 'description': 'File or directory to search (must be under /workspace, default: /workspace)', + 'default': '/workspace', + }, + 'include': { + 'type': 'string', + 'description': 'Only search files matching this glob (e.g. *.py)', + }, + }, + 'required': ['pattern'], + 'additionalProperties': False, + }, + func=lambda parameters: parameters, + ) + + async def _invoke_glob(self, parameters: dict, query: pipeline_query.Query) -> dict: + pattern = parameters['pattern'] + path = str(parameters.get('path', '/workspace') or '/workspace') + self.ap.logger.info(f'glob tool invoked: query_id={query.query_id} pattern={pattern} path={path}') + + host_path, selected_skill = self._resolve_host_path( + query, + path, + include_visible=True, + include_activated=True, + ) + if self._should_use_box_workspace_files(selected_skill): + return await self._glob_workspace_via_box(path, pattern, query) + + if not os.path.isdir(host_path): + return {'ok': False, 'error': f'Path is not a directory: {path}'} + + from pathlib import Path + + base = Path(host_path) + hits = list(base.rglob(pattern)) + + # Filter out skipped directories + hits = [h for h in hits if not any(skip in h.parts for skip in _SKIP_DIRS)] + + # Sort by mtime, newest first + hits.sort(key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True) + + total = len(hits) + shown = hits[:_GLOB_MAX_MATCHES] + + # Convert back to sandbox paths + sandbox_paths = [] + output_bytes = 0 + truncated_by_bytes = False + for h in shown: + rel = os.path.relpath(str(h), host_path) + sandbox_path = os.path.join(path, rel) + entry_bytes = len(sandbox_path.encode('utf-8')) + (1 if sandbox_paths else 0) + if output_bytes + entry_bytes > _DEFAULT_TOOL_RESULT_MAX_BYTES: + truncated_by_bytes = True + break + sandbox_paths.append(sandbox_path) + output_bytes += entry_bytes + + return { + 'ok': True, + 'matches': sandbox_paths, + 'preview': '\n'.join(sandbox_paths), + 'total': total, + 'truncated': total > len(sandbox_paths) or truncated_by_bytes, + 'truncated_by': 'bytes' if truncated_by_bytes else ('matches' if total > len(sandbox_paths) else None), + } + + async def _invoke_grep(self, parameters: dict, query: pipeline_query.Query) -> dict: + pattern = parameters['pattern'] + path = str(parameters.get('path', '/workspace') or '/workspace') + include = parameters.get('include') + self.ap.logger.info(f'grep tool invoked: query_id={query.query_id} pattern={pattern} path={path}') + + import re + from pathlib import Path + + try: + regex = re.compile(pattern) + except re.error as e: + return {'ok': False, 'error': f'Invalid regex: {e}'} + + host_path, selected_skill = self._resolve_host_path( + query, + path, + include_visible=True, + include_activated=True, + ) + if self._should_use_box_workspace_files(selected_skill): + return await self._grep_workspace_via_box(path, pattern, include, query) + + if not os.path.exists(host_path): + return {'ok': False, 'error': f'Path not found: {path}'} + + base = Path(host_path) + + if base.is_file(): + files = [base] + else: + files = self._grep_walk(base, include) + + matches = [] + output_bytes = 0 + truncated_by = None + for fp in files: + try: + handle = fp.open('r', encoding='utf-8', errors='ignore') + except OSError: + continue + with handle: + for lineno, line in enumerate(handle, 1): + if regex.search(line): + rel = os.path.relpath(str(fp), host_path) + sandbox_path = os.path.join(path, rel) + content, line_truncated = self._truncate_grep_line(line.rstrip()) + entry = { + 'file': sandbox_path, + 'line': lineno, + 'content': content, + } + entry_bytes = len(json.dumps(entry, ensure_ascii=False).encode('utf-8')) + 1 + if output_bytes + entry_bytes > _DEFAULT_TOOL_RESULT_MAX_BYTES: + truncated_by = 'bytes' + break + if line_truncated and truncated_by is None: + truncated_by = 'line' + matches.append(entry) + output_bytes += entry_bytes + if len(matches) >= _GREP_MAX_MATCHES: + truncated_by = truncated_by or 'matches' + break + if truncated_by == 'bytes' or len(matches) >= _GREP_MAX_MATCHES: + break + if truncated_by == 'bytes' or len(matches) >= _GREP_MAX_MATCHES: + break + + return { + 'ok': True, + 'matches': matches, + 'total': len(matches), + 'truncated': truncated_by is not None, + 'truncated_by': truncated_by, + } + + @staticmethod + def _grep_walk(root, include: str | None) -> list: + """Walk dir tree for grep, skipping junk dirs.""" + results = [] + for item in root.rglob(include or '*'): + if any(skip in item.parts for skip in _SKIP_DIRS): + continue + if item.is_file(): + results.append(item) + if len(results) >= _GREP_MAX_FILES: + break + return results + + @staticmethod + def _resolve_skill_host_path(selected_skill: dict, relative: str) -> str | None: + package_root = str(selected_skill.get('package_root', '') or '').strip() + if not package_root: + return None + + host_root = os.path.realpath(package_root) + host_path = os.path.realpath(os.path.join(host_root, relative)) + if not (host_path == host_root or host_path.startswith(host_root + os.sep)): + raise ValueError('Path escapes the skill package boundary.') + return host_path + + def _normalize_exec_result(self, result: dict) -> dict: + normalized = dict(result) + stdout = str(normalized.get('stdout') or '') + stderr = str(normalized.get('stderr') or '') + stdout, stdout_capped = self._truncate_text_to_bytes_with_flag(stdout, _DEFAULT_TOOL_RESULT_MAX_BYTES) + stderr, stderr_capped = self._truncate_text_to_bytes_with_flag(stderr, _DEFAULT_TOOL_RESULT_MAX_BYTES) + normalized['stdout'] = stdout + normalized['stderr'] = stderr + normalized['stdout_truncated'] = bool(normalized.get('stdout_truncated') or stdout_capped) + normalized['stderr_truncated'] = bool(normalized.get('stderr_truncated') or stderr_capped) + + if stdout and stderr: + preview_raw = f'stdout:\n{stdout}\n\nstderr:\n{stderr}' + else: + preview_raw = stdout or stderr + preview, preview_capped = self._truncate_text_to_bytes_with_flag(preview_raw, _DEFAULT_TOOL_RESULT_MAX_BYTES) + normalized['preview'] = preview + normalized['truncated'] = bool( + normalized['stdout_truncated'] or normalized['stderr_truncated'] or preview_capped + ) + if preview_capped and not normalized.get('truncated_by'): + normalized['truncated_by'] = 'bytes' + return normalized + + def _build_directory_result(self, entries: list[str]) -> dict: + sorted_entries = sorted(str(entry) for entry in entries) + content = '\n'.join(sorted_entries) + preview = self._truncate_text_to_bytes(content, _DEFAULT_TOOL_RESULT_MAX_BYTES) + truncated = preview != content + return { + 'ok': True, + 'content': preview, + 'is_directory': True, + 'total': len(sorted_entries), + 'truncated': truncated, + 'truncated_by': 'bytes' if truncated else None, + } + + def _read_text_file_preview(self, host_path: str, parameters: dict) -> dict: + if self._read_encoding(parameters) == 'base64': + return self._read_binary_file_chunk(host_path, parameters) + + offset = self._positive_int(parameters.get('offset'), default=1) + max_lines = self._positive_int( + parameters.get('limit'), + default=_DEFAULT_READ_MAX_LINES, + max_value=_MAX_READ_MAX_LINES, + ) + max_bytes = self._positive_int( + parameters.get('max_bytes'), + default=_DEFAULT_TOOL_RESULT_MAX_BYTES, + max_value=_DEFAULT_TOOL_RESULT_MAX_BYTES, + ) + lines: list[str] = [] + output_bytes = 0 + end_line = offset - 1 + truncated = False + truncated_by: str | None = None + next_offset: int | None = None + + with open(host_path, 'r', encoding='utf-8', errors='replace') as f: + for line_number, line in enumerate(f, 1): + if line_number < offset: + continue + if len(lines) >= max_lines: + truncated = True + truncated_by = 'lines' + next_offset = line_number + break + + line_bytes = len(line.encode('utf-8')) + if output_bytes + line_bytes > max_bytes: + truncated = True + truncated_by = 'bytes' + next_offset = line_number + break + + lines.append(line.rstrip('\n')) + output_bytes += line_bytes + end_line = line_number + + if not lines and truncated_by == 'bytes': + content = ( + f'[Line {next_offset or offset} exceeds the {self._format_size(max_bytes)} read limit. ' + 'Use exec with a byte-range command for this line, or read a different offset.]' + ) + else: + content = '\n'.join(lines) + + return { + 'ok': True, + 'content': content, + 'truncated': truncated, + 'truncated_by': truncated_by, + 'start_line': offset, + 'end_line': end_line, + 'next_offset': next_offset, + 'max_lines': max_lines, + 'max_bytes': max_bytes, + } + + def _read_binary_file_chunk(self, host_path: str, parameters: dict) -> dict: + byte_offset = self._non_negative_int(parameters.get('byte_offset'), default=0) + max_bytes = self._positive_int( + parameters.get('max_bytes'), + default=_DEFAULT_TOOL_RESULT_MAX_BYTES, + max_value=_DEFAULT_TOOL_RESULT_MAX_BYTES, + ) + size_bytes = os.path.getsize(host_path) + with open(host_path, 'rb') as f: + f.seek(byte_offset) + data = f.read(max_bytes + 1) + chunk = data[:max_bytes] + has_more = len(data) > max_bytes + return { + 'ok': True, + 'content': base64.b64encode(chunk).decode('ascii'), + 'encoding': 'base64', + 'byte_offset': byte_offset, + 'length': len(chunk), + 'size_bytes': size_bytes, + 'has_more': has_more, + 'next_byte_offset': byte_offset + len(chunk) if has_more else None, + 'max_bytes': max_bytes, + } + + def _write_host_file(self, host_path: str, content: str, parameters: dict) -> None: + encoding, mode = self._write_options(parameters) + if encoding == 'base64': + try: + data = base64.b64decode(content, validate=True) + except Exception as exc: + raise ValueError(f'invalid base64 content: {exc}') from exc + with open(host_path, 'ab' if mode == 'append' else 'wb') as f: + f.write(data) + return + with open(host_path, 'a' if mode == 'append' else 'w', encoding='utf-8') as f: + f.write(content) + + @staticmethod + def _read_encoding(parameters: dict) -> str: + return 'base64' if parameters.get('encoding') == 'base64' else 'text' + + @staticmethod + def _write_options(parameters: dict) -> tuple[str, str]: + encoding = 'base64' if parameters.get('encoding') == 'base64' else 'text' + mode = 'append' if parameters.get('mode') == 'append' else 'overwrite' + return encoding, mode + + def _build_read_result_from_text(self, content: str, parameters: dict) -> dict: + offset = self._positive_int(parameters.get('offset'), default=1) + max_lines = self._positive_int( + parameters.get('limit'), + default=_DEFAULT_READ_MAX_LINES, + max_value=_MAX_READ_MAX_LINES, + ) + max_bytes = self._positive_int( + parameters.get('max_bytes'), + default=_DEFAULT_TOOL_RESULT_MAX_BYTES, + max_value=_DEFAULT_TOOL_RESULT_MAX_BYTES, + ) + all_lines = content.splitlines() + start_index = offset - 1 + if start_index >= len(all_lines) and all_lines: + return {'ok': False, 'error': f'Offset {offset} is beyond end of file ({len(all_lines)} lines total)'} + output_lines: list[str] = [] + output_bytes = 0 + truncated = False + truncated_by: str | None = None + next_offset: int | None = None + for index, line in enumerate(all_lines[start_index:], start_index + 1): + if len(output_lines) >= max_lines: + truncated = True + truncated_by = 'lines' + next_offset = index + break + line_bytes = len(line.encode('utf-8')) + (1 if output_lines else 0) + if output_bytes + line_bytes > max_bytes: + truncated = True + truncated_by = 'bytes' + next_offset = index + break + output_lines.append(line) + output_bytes += line_bytes + + end_line = offset + len(output_lines) - 1 + return { + 'ok': True, + 'content': '\n'.join(output_lines), + 'truncated': truncated, + 'truncated_by': truncated_by, + 'start_line': offset, + 'end_line': end_line, + 'next_offset': next_offset, + 'max_lines': max_lines, + 'max_bytes': max_bytes, + } + + @staticmethod + def _positive_int(value, *, default: int, max_value: int | None = None) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = default + if parsed <= 0: + parsed = default + if max_value is not None: + parsed = min(parsed, max_value) + return parsed + + @staticmethod + def _non_negative_int(value, *, default: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = default + return parsed if parsed >= 0 else default + + @staticmethod + def _truncate_grep_line(line: str) -> tuple[str, bool]: + if len(line) <= _GREP_MAX_LINE_CHARS: + return line, False + return f'{line[:_GREP_MAX_LINE_CHARS]}... [truncated]', True + + @staticmethod + def _truncate_text_to_bytes(text: str, max_bytes: int) -> str: + return NativeToolLoader._truncate_text_to_bytes_with_flag(text, max_bytes)[0] + + @staticmethod + def _truncate_text_to_bytes_with_flag(text: str, max_bytes: int) -> tuple[str, bool]: + data = text.encode('utf-8') + if len(data) <= max_bytes: + return text, False + truncated = data[:max_bytes] + while truncated and (truncated[-1] & 0xC0) == 0x80: + truncated = truncated[:-1] + return truncated.decode('utf-8', errors='ignore'), True + + @staticmethod + def _format_size(bytes_count: int) -> str: + if bytes_count < 1024: + return f'{bytes_count}B' + return f'{bytes_count / 1024:.1f}KB' + + def _summarize_parameters(self, parameters: dict) -> dict: + summary = dict(parameters) + cmd = str(summary.get('command', '')).strip() + if len(cmd) > 400: + cmd = f'{cmd[:397]}...' + summary['command'] = cmd + + env = summary.get('env') + if isinstance(env, dict): + summary['env_keys'] = sorted(str(key) for key in env.keys()) + del summary['env'] + + return summary diff --git a/src/langbot/pkg/provider/tools/loaders/plugin.py b/src/langbot/pkg/provider/tools/loaders/plugin.py index 5b741848e..baac91d1d 100644 --- a/src/langbot/pkg/provider/tools/loaders/plugin.py +++ b/src/langbot/pkg/provider/tools/loaders/plugin.py @@ -3,6 +3,7 @@ from __future__ import annotations import typing import traceback +from langbot_plugin.api.definition.components.manifest import ComponentManifest from langbot_plugin.api.entities.events import pipeline_query from .. import loader @@ -32,6 +33,24 @@ class PluginToolLoader(loader.ToolLoader): return all_functions + async def get_tool_catalog(self, bound_plugins: list[str] | None = None) -> list[dict[str, typing.Any]]: + catalog: list[dict[str, typing.Any]] = [] + + for tool in await self.ap.plugin_connector.list_tools(bound_plugins): + catalog.append( + { + 'name': tool.metadata.name, + 'description': tool.spec['llm_prompt'], + 'human_desc': tool.metadata.description.en_US, + 'parameters': tool.spec['parameters'], + 'source': 'plugin', + 'source_name': tool.owner, + 'source_id': tool.owner, + } + ) + + return catalog + async def has_tool(self, name: str) -> bool: """检查工具是否存在""" for tool in await self.ap.plugin_connector.list_tools(): @@ -39,7 +58,7 @@ class PluginToolLoader(loader.ToolLoader): return True return False - async def _get_tool(self, name: str) -> resource_tool.LLMTool: + async def get_tool(self, name: str) -> ComponentManifest | None: for tool in await self.ap.plugin_connector.list_tools(): if tool.metadata.name == name: return tool diff --git a/src/langbot/pkg/provider/tools/loaders/skill.py b/src/langbot/pkg/provider/tools/loaders/skill.py new file mode 100644 index 000000000..b62f3e7d5 --- /dev/null +++ b/src/langbot/pkg/provider/tools/loaders/skill.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import re +import typing + +from ....box import workspace as box_workspace + +if typing.TYPE_CHECKING: + from ....core import app + from langbot_plugin.api.entities.events import pipeline_query + +ACTIVATED_SKILLS_KEY = '_activated_skills' +PIPELINE_BOUND_SKILLS_KEY = '_pipeline_bound_skills' +SKILL_MOUNT_PREFIX = '/workspace/.skills' +_SKILL_MOUNT_PATTERN = re.compile(r'/workspace/\.skills/([A-Za-z0-9_-]+)') + + +def get_virtual_skill_mount_path(skill_name: str) -> str: + return f'{SKILL_MOUNT_PREFIX}/{skill_name}' + + +def get_bound_skill_names(query: pipeline_query.Query) -> list[str] | None: + if query.variables is None: + return None + + bound_skills = query.variables.get(PIPELINE_BOUND_SKILLS_KEY) + if bound_skills is None: + return None + if isinstance(bound_skills, list): + return [str(item) for item in bound_skills] + return None + + +def get_visible_skills(ap: app.Application, query: pipeline_query.Query) -> dict[str, dict]: + skill_mgr = getattr(ap, 'skill_mgr', None) + if skill_mgr is None: + return {} + + visible_skills = getattr(skill_mgr, 'skills', {}) + bound_skills = get_bound_skill_names(query) + if bound_skills is None: + return visible_skills + + return {skill_name: skill_data for skill_name, skill_data in visible_skills.items() if skill_name in bound_skills} + + +def get_visible_skill(ap: app.Application, query: pipeline_query.Query, skill_name: str) -> dict | None: + return get_visible_skills(ap, query).get(skill_name) + + +def get_activated_skills(query: pipeline_query.Query) -> dict[str, dict]: + if query.variables is None: + return {} + + activated = query.variables.get(ACTIVATED_SKILLS_KEY, {}) + if not isinstance(activated, dict): + return {} + return activated + + +def get_activated_skill(query: pipeline_query.Query, skill_name: str) -> dict | None: + return get_activated_skills(query).get(skill_name) + + +def register_activated_skill(query: pipeline_query.Query, skill_data: dict) -> None: + if query.variables is None: + query.variables = {} + + activated = query.variables.setdefault(ACTIVATED_SKILLS_KEY, {}) + skill_name = str(skill_data.get('name', '') or '').strip() + if skill_name and skill_name not in activated: + activated[skill_name] = skill_data + + +def normalize_skill_names(value: typing.Any) -> list[str]: + """Return a de-duplicated list of non-empty skill names.""" + if not isinstance(value, list): + return [] + + names: list[str] = [] + for item in value: + skill_name = str(item or '').strip() + if skill_name and skill_name not in names: + names.append(skill_name) + return names + + +def get_activated_skill_names(query: pipeline_query.Query) -> list[str]: + """Return activated skill names for callers that own persistence policy.""" + return normalize_skill_names(list(get_activated_skills(query).keys())) + + +def restore_activated_skills( + ap: app.Application, + query: pipeline_query.Query, + skill_names: typing.Any, +) -> list[str]: + """Restore caller-provided activated skill names into Query variables. + + Persistence and state scope ownership belong to higher-level flows. This + helper only rebuilds current Query state from pipeline-visible skills, so + removed or unbound skills stay unavailable to native exec/write/edit. + """ + restored: list[str] = [] + for skill_name in normalize_skill_names(skill_names): + skill_data = get_visible_skill(ap, query, skill_name) + if skill_data is None: + continue + register_activated_skill(query, skill_data) + restored.append(skill_name) + return restored + + +def parse_skill_mount_path(sandbox_path: str) -> tuple[str | None, str]: + normalized_path = str(sandbox_path or '/workspace').strip() or '/workspace' + if normalized_path == SKILL_MOUNT_PREFIX: + raise ValueError(f'Path must include a skill name under {SKILL_MOUNT_PREFIX}/.') + prefix = f'{SKILL_MOUNT_PREFIX}/' + if not normalized_path.startswith(prefix): + return None, normalized_path + + remainder = normalized_path[len(prefix) :] + skill_name, separator, tail = remainder.partition('/') + if not skill_name: + raise ValueError(f'Path must include a skill name under {SKILL_MOUNT_PREFIX}/.') + + rewritten_path = '/workspace' + if separator: + rewritten_path = f'/workspace/{tail}' + return skill_name, rewritten_path + + +def resolve_virtual_skill_path( + ap: app.Application, + query: pipeline_query.Query, + sandbox_path: str, + *, + include_visible: bool, + include_activated: bool, +) -> tuple[dict | None, str]: + skill_name, rewritten_path = parse_skill_mount_path(sandbox_path) + if skill_name is None: + return None, rewritten_path + + if include_activated: + activated_skill = get_activated_skill(query, skill_name) + if activated_skill is not None: + return activated_skill, rewritten_path + + if include_visible: + visible_skill = get_visible_skill(ap, query, skill_name) + if visible_skill is not None: + return visible_skill, rewritten_path + + activated_names = ', '.join(sorted(get_activated_skills(query).keys())) or 'none' + visible_names = ', '.join(sorted(get_visible_skills(ap, query).keys())) or 'none' + raise ValueError( + f'Skill "{skill_name}" is not available at this path. ' + f'Activated skills: {activated_names}. Visible skills: {visible_names}.' + ) + + +def find_referenced_skill_names(text: str) -> list[str]: + if not text: + return [] + + seen: list[str] = [] + for match in _SKILL_MOUNT_PATTERN.findall(text): + if match not in seen: + seen.append(match) + return seen + + +def rewrite_command_for_skill_mount(command: str, skill_name: str) -> str: + virtual_root = get_virtual_skill_mount_path(skill_name) + rewritten = command.replace(f'{virtual_root}/', '/workspace/') + return rewritten.replace(virtual_root, '/workspace') + + +def build_skill_session_id(skill_data: dict, query: pipeline_query.Query) -> str: + skill_identifier = str(skill_data.get('name', 'unknown') or 'unknown') + launcher_type = getattr(query, 'launcher_type', None) + launcher_id = getattr(query, 'launcher_id', None) + query_id = getattr(query, 'query_id', 'unknown') + + if launcher_type is not None and launcher_id is not None: + return f'skill-{launcher_type}_{launcher_id}-{skill_identifier}' + return f'skill-{query_id}-{skill_identifier}' + + +def should_prepare_skill_python_env(package_root: str | None) -> bool: + return box_workspace.should_prepare_python_env(package_root) + + +def wrap_skill_command_with_python_env(command: str, *, mount_path: str = '/workspace') -> str: + return box_workspace.wrap_python_command_with_env(command, mount_path=mount_path).rstrip() diff --git a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py new file mode 100644 index 000000000..d53721785 --- /dev/null +++ b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import os +import typing + +import langbot_plugin.api.entities.builtin.resource.tool as resource_tool + +from .. import loader +from .availability import is_box_backend_available + +# Align with Claude Code's Skill tool design: +# - activate: Activate a skill via Tool Call, returns SKILL.md content +# - register_skill: Register a skill from sandbox directory to data/skills/ +# - This protects KV Cache and follows industry standard + +ACTIVATE_SKILL_TOOL_NAME = 'activate' +REGISTER_SKILL_TOOL_NAME = 'register_skill' + +SKILL_TOOL_NAMES = { + ACTIVATE_SKILL_TOOL_NAME, + REGISTER_SKILL_TOOL_NAME, +} + + +class SkillToolLoader(loader.ToolLoader): + """Skill tools aligned with Claude Code's design.""" + + def __init__(self, ap): + super().__init__(ap) + self._tools: list[resource_tool.LLMTool] = [] + self._sandbox_available: bool = False + + async def initialize(self): + # Check if sandbox backend is available (same check as native tools) + self._sandbox_available = await self._check_sandbox_available() + if self._sandbox_available: + self._tools = [ + self._build_activate_skill_tool(), + self._build_register_skill_tool(), + ] + else: + self.ap.logger.info( + 'Skill tools (activate/register_skill) are NOT available. ' + 'No sandbox backend (Docker/nsjail/E2B) is ready.' + ) + + async def _check_sandbox_available(self) -> bool: + """Check if the box backend is truly available (not just the runtime).""" + return await is_box_backend_available(self.ap) + + async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]: + if not self._is_available(): + return [] + return list(self._tools) + + async def has_tool(self, name: str) -> bool: + return self._is_available() and name in SKILL_TOOL_NAMES + + def _is_available(self) -> bool: + """Check if skill tools should be available. + + Skill tools require both a skill manager and a sandbox backend. + """ + return self._has_skill_manager() and self._sandbox_available + + async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any: + if name == ACTIVATE_SKILL_TOOL_NAME: + return await self._invoke_activate_skill(parameters, query) + if name == REGISTER_SKILL_TOOL_NAME: + return await self._invoke_register_skill(parameters) + raise ValueError(f'Unknown skill tool: {name}') + + async def shutdown(self): + pass + + def _has_skill_manager(self) -> bool: + return getattr(self.ap, 'skill_mgr', None) is not None + + async def _invoke_activate_skill(self, parameters: dict, query) -> typing.Any: + """Activate a skill and return SKILL.md content via Tool Result.""" + skill_name = str(parameters.get('skill_name', '') or '').strip() + if not skill_name: + raise ValueError('skill_name is required') + + from . import skill as skill_loader + + skill_data = skill_loader.get_visible_skill(self.ap, query, skill_name) + if skill_data is None: + visible_skills = skill_loader.get_visible_skills(self.ap, query) + available_names = ', '.join(sorted(visible_skills.keys())) or 'none' + raise ValueError(f'Skill "{skill_name}" not found. Available skills: {available_names}') + + # Register activated skill for sandbox mount path resolution + skill_loader.register_activated_skill(query, skill_data) + + # Return SKILL.md content as Tool Result (injects into context) + instructions = skill_data.get('instructions', '') + package_root = skill_data.get('package_root', '') + mount_path = skill_loader.get_virtual_skill_mount_path(skill_name) + + # Build Tool Result content + result_content = f'The "{skill_name}" skill is activated\n' + result_content += '\n' + result_content += f'{skill_name}\n' + result_content += f'{mount_path}\n' + result_content += f'{package_root}\n' + result_content += f'\n## Instructions\n{instructions}\n' + result_content += '\n## Runtime Context\n' + result_content += f'The skill package is mounted at {mount_path}. Use the standard tools to interact with it:\n' + result_content += f'- Use `read` to inspect files under {mount_path}\n' + result_content += f'- Use `exec` with workdir set to {mount_path} to run commands in that package\n' + result_content += '- Use `write` and `edit` on that path when the instructions require updating files\n' + result_content += '\n' + + return { + 'activated': True, + 'skill_name': skill_name, + 'mount_path': mount_path, + 'activated_skill_names': skill_loader.get_activated_skill_names(query), + 'content': result_content, + } + + async def _invoke_register_skill(self, parameters: dict) -> typing.Any: + """Register a skill from sandbox directory to data/skills/.""" + sandbox_path = str(parameters.get('path', '') or '').strip() + if not sandbox_path: + raise ValueError('path is required') + + # Resolve sandbox path to host path + host_path = self._resolve_workspace_directory(sandbox_path) + + # Get or create skill service + skill_service = getattr(self.ap, 'skill_service', None) + if skill_service is None: + raise ValueError('Skill service not available') + + # Scan and register the skill + scanned = await skill_service.scan_directory_async(host_path) + + # Override name if provided + skill_name = str(parameters.get('name') or scanned['name']).strip() + if not skill_name: + raise ValueError('skill name is required') + + # Create the skill + created = await skill_service.create_skill( + { + 'name': skill_name, + 'display_name': str(parameters.get('display_name') or scanned.get('display_name', '')).strip(), + 'description': str(parameters.get('description') or scanned.get('description', '')).strip(), + 'instructions': str(parameters.get('instructions') or scanned.get('instructions', '')), + 'package_root': host_path, + } + ) + + return { + 'registered': True, + 'skill_name': skill_name, + 'source_path': sandbox_path, + 'skill': created, + } + + def _resolve_workspace_directory(self, sandbox_path: str) -> str: + """Resolve sandbox path to host filesystem path.""" + box_service = getattr(self.ap, 'box_service', None) + workspace_root = getattr(box_service, 'default_workspace', None) + if not workspace_root: + raise ValueError('No default workspace configured') + + normalized_path = str(sandbox_path).strip() or '/workspace' + if not normalized_path.startswith('/workspace'): + raise ValueError('path must be under /workspace') + + relative = normalized_path[len('/workspace') :].lstrip('/') + host_root = os.path.realpath(workspace_root) + host_path = os.path.realpath(os.path.join(host_root, relative)) + + # Security check: ensure path doesn't escape workspace + if not (host_path == host_root or host_path.startswith(host_root + os.sep)): + raise ValueError('path escapes the workspace boundary') + + if getattr(box_service, 'available', False): + return host_path + + if not os.path.isdir(host_path): + raise ValueError(f'Directory does not exist: {sandbox_path}') + + return host_path + + def _build_activate_skill_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=ACTIVATE_SKILL_TOOL_NAME, + human_desc='Activate a skill', + description='Activate a pipeline-visible skill by name and return its instructions as a tool result.', + parameters={ + 'type': 'object', + 'properties': { + 'skill_name': { + 'type': 'string', + 'description': 'The skill name to activate.', + }, + }, + 'required': ['skill_name'], + 'additionalProperties': False, + }, + func=lambda parameters: parameters, + ) + + def _build_register_skill_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=REGISTER_SKILL_TOOL_NAME, + human_desc='Register a skill from sandbox', + description=( + "Register a skill package from a directory under /workspace into LangBot's skill store. " + 'Use this after creating or preparing a skill in the sandbox with exec/read/write/edit. ' + 'The directory must contain a SKILL.md file. ' + 'After registration, the skill can be activated with the activate tool.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'path': { + 'type': 'string', + 'description': 'Directory path under /workspace containing the skill package (must have SKILL.md)', + }, + 'name': { + 'type': 'string', + 'description': 'Optional skill name override. Defaults to the name in SKILL.md or directory name.', + }, + 'display_name': { + 'type': 'string', + 'description': 'Optional display name override.', + }, + 'description': { + 'type': 'string', + 'description': 'Optional description override.', + }, + 'instructions': { + 'type': 'string', + 'description': 'Optional instructions override.', + }, + }, + 'required': ['path'], + 'additionalProperties': False, + }, + func=lambda parameters: parameters, + ) diff --git a/src/langbot/pkg/provider/tools/toolmgr.py b/src/langbot/pkg/provider/tools/toolmgr.py index f921c094e..60a16ce7e 100644 --- a/src/langbot/pkg/provider/tools/toolmgr.py +++ b/src/langbot/pkg/provider/tools/toolmgr.py @@ -1,15 +1,23 @@ from __future__ import annotations import typing +import time +from typing import TYPE_CHECKING -from ...core import app -from langbot.pkg.utils import importutil -from langbot.pkg.provider.tools import loaders -from langbot.pkg.provider.tools.loaders import mcp as mcp_loader, plugin as plugin_loader import langbot_plugin.api.entities.builtin.resource.tool as resource_tool from langbot_plugin.api.entities.events import pipeline_query -importutil.import_modules_in_pkg(loaders) +from . import loader as tool_loader +from .errors import ToolNotFoundError + +if TYPE_CHECKING: + from ...core import app + from langbot.pkg.provider.tools.loaders import ( + mcp as mcp_loader, + native as native_loader, + plugin as plugin_loader, + skill_authoring as skill_authoring_loader, + ) class ToolManager: @@ -17,31 +25,109 @@ class ToolManager: ap: app.Application + native_tool_loader: native_loader.NativeToolLoader plugin_tool_loader: plugin_loader.PluginToolLoader mcp_tool_loader: mcp_loader.MCPLoader + skill_tool_loader: skill_authoring_loader.SkillToolLoader def __init__(self, ap: app.Application): self.ap = ap async def initialize(self): + from langbot.pkg.utils import importutil + from langbot.pkg.provider.tools import loaders + from langbot.pkg.provider.tools.loaders import ( + mcp as mcp_loader, + native as native_loader, + plugin as plugin_loader, + skill_authoring as skill_authoring_loader, + ) + + importutil.import_modules_in_pkg(loaders) + + self.native_tool_loader = native_loader.NativeToolLoader(self.ap) + await self.native_tool_loader.initialize() + self.plugin_tool_loader = plugin_loader.PluginToolLoader(self.ap) await self.plugin_tool_loader.initialize() self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap) await self.mcp_tool_loader.initialize() + self.skill_tool_loader = skill_authoring_loader.SkillToolLoader(self.ap) + await self.skill_tool_loader.initialize() async def get_all_tools( - self, bound_plugins: list[str] | None = None, bound_mcp_servers: list[str] | None = None + self, + bound_plugins: list[str] | None = None, + bound_mcp_servers: list[str] | None = None, + include_skill_authoring: bool = False, + include_mcp_resource_tools: bool = True, ) -> list[resource_tool.LLMTool]: - """获取所有函数""" all_functions: list[resource_tool.LLMTool] = [] + all_functions.extend(await self.native_tool_loader.get_tools()) + if include_skill_authoring: + all_functions.extend(await self.skill_tool_loader.get_tools()) all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins)) - all_functions.extend(await self.mcp_tool_loader.get_tools(bound_mcp_servers)) + all_functions.extend( + await self.mcp_tool_loader.get_tools( + bound_mcp_servers, + include_resource_tools=include_mcp_resource_tools, + ) + ) return all_functions + async def get_tool_catalog( + self, + bound_plugins: list[str] | None = None, + bound_mcp_servers: list[str] | None = None, + include_skill_authoring: bool = False, + include_mcp_resource_tools: bool = False, + ) -> list[dict[str, typing.Any]]: + catalog: list[dict[str, typing.Any]] = [] + + def append_tools(source: str, source_name: str, tools: list[resource_tool.LLMTool]) -> None: + for tool in tools: + catalog.append( + { + 'name': tool.name, + 'description': tool.description, + 'human_desc': tool.human_desc, + 'parameters': tool.parameters, + 'source': source, + 'source_name': source_name, + } + ) + + append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools()) + if include_skill_authoring: + append_tools('skill', 'LangBot', await self.skill_tool_loader.get_tools()) + catalog.extend(await self.plugin_tool_loader.get_tool_catalog(bound_plugins)) + + if self.mcp_tool_loader: + for item in await self.mcp_tool_loader.get_tool_catalog( + bound_mcp_servers, + include_resource_tools=include_mcp_resource_tools, + ): + catalog.append(item) + + return catalog + + async def get_tool_by_name(self, name: str) -> tool_loader.ToolLookupResult | None: + """Get tool by name from any active loader.""" + for active_loader in ( + self.native_tool_loader, + self.plugin_tool_loader, + self.mcp_tool_loader, + self.skill_tool_loader, + ): + tool = await active_loader.get_tool(name) + if tool: + return tool + + return None + async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list: - """生成函数列表""" tools = [] for function in use_funcs: @@ -57,52 +143,134 @@ class ToolManager: return tools - async def generate_tools_for_anthropic(self, use_funcs: list[resource_tool.LLMTool]) -> list: - """为anthropic生成函数列表 + def _get_query_session_id(self, query: pipeline_query.Query) -> str | None: + launcher_type = getattr(query, 'launcher_type', None) + launcher_id = getattr(query, 'launcher_id', None) + if launcher_type is None or launcher_id is None: + return None - e.g. + launcher_type_value = launcher_type.value if hasattr(launcher_type, 'value') else launcher_type + return f'{launcher_type_value}_{launcher_id}' - [ - { - "name": "get_stock_price", - "description": "Get the current stock price for a given ticker symbol.", - "input_schema": { - "type": "object", - "properties": { - "ticker": { - "type": "string", - "description": "The stock ticker symbol, e.g. AAPL for Apple Inc." - } - }, - "required": ["ticker"] - } - } - ] - """ + async def _record_tool_call( + self, + *, + name: str, + source: str, + parameters: dict, + query: pipeline_query.Query, + duration_ms: int, + status: str, + result: typing.Any = None, + error_message: str | None = None, + ) -> None: + monitoring_service = getattr(self.ap, 'monitoring_service', None) + if not monitoring_service: + return - tools = [] + variables = getattr(query, 'variables', {}) or {} + message_id = variables.get('_monitoring_message_id') if isinstance(variables, dict) else None + bot_name = variables.get('_monitoring_bot_name') if isinstance(variables, dict) else None + pipeline_name = variables.get('_monitoring_pipeline_name') if isinstance(variables, dict) else None - for function in use_funcs: - function_schema = { - 'name': function.name, - 'description': function.description, - 'input_schema': function.parameters, - } - tools.append(function_schema) + try: + await monitoring_service.record_tool_call( + tool_name=name, + tool_source=source, + duration=duration_ms, + status=status, + bot_id=getattr(query, 'bot_uuid', None), + bot_name=bot_name, + pipeline_name=pipeline_name, + session_id=self._get_query_session_id(query), + message_id=message_id, + arguments=parameters, + result=result, + error_message=error_message, + ) + except Exception as e: + self.ap.logger.warning(f'Failed to record tool call: {e}') - return tools + async def _invoke_tool_with_monitoring( + self, + *, + source: str, + name: str, + parameters: dict, + query: pipeline_query.Query, + invoke: typing.Callable[[], typing.Awaitable[typing.Any]], + ) -> typing.Any: + start_time = time.perf_counter() + try: + result = await invoke() + except Exception as e: + duration_ms = int((time.perf_counter() - start_time) * 1000) + await self._record_tool_call( + name=name, + source=source, + parameters=parameters, + query=query, + duration_ms=duration_ms, + status='error', + error_message=str(e), + ) + raise + + duration_ms = int((time.perf_counter() - start_time) * 1000) + await self._record_tool_call( + name=name, + source=source, + parameters=parameters, + query=query, + duration_ms=duration_ms, + status='success', + result=result, + ) + return result async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any: - """执行函数调用""" + from langbot.pkg.telemetry import features as telemetry_features + if await self.native_tool_loader.has_tool(name): + telemetry_features.increment(query, 'tool_calls', 'native') + return await self._invoke_tool_with_monitoring( + source='native', + name=name, + parameters=parameters, + query=query, + invoke=lambda: self.native_tool_loader.invoke_tool(name, parameters, query), + ) if await self.plugin_tool_loader.has_tool(name): - return await self.plugin_tool_loader.invoke_tool(name, parameters, query) - elif await self.mcp_tool_loader.has_tool(name): - return await self.mcp_tool_loader.invoke_tool(name, parameters, query) - else: - raise ValueError(f'未找到工具: {name}') + telemetry_features.increment(query, 'tool_calls', 'plugin') + return await self._invoke_tool_with_monitoring( + source='plugin', + name=name, + parameters=parameters, + query=query, + invoke=lambda: self.plugin_tool_loader.invoke_tool(name, parameters, query), + ) + if await self.mcp_tool_loader.has_tool(name): + telemetry_features.increment(query, 'tool_calls', 'mcp') + return await self._invoke_tool_with_monitoring( + source='mcp', + name=name, + parameters=parameters, + query=query, + invoke=lambda: self.mcp_tool_loader.invoke_tool(name, parameters, query), + ) + if await self.skill_tool_loader.has_tool(name): + telemetry_features.increment(query, 'tool_calls', 'skill') + return await self._invoke_tool_with_monitoring( + source='skill', + name=name, + parameters=parameters, + query=query, + invoke=lambda: self.skill_tool_loader.invoke_tool(name, parameters, query), + ) + raise ToolNotFoundError(name) async def shutdown(self): - """关闭所有工具""" + await self.native_tool_loader.shutdown() await self.plugin_tool_loader.shutdown() await self.mcp_tool_loader.shutdown() + await self.skill_tool_loader.shutdown() diff --git a/src/langbot/pkg/skill/__init__.py b/src/langbot/pkg/skill/__init__.py new file mode 100644 index 000000000..b96f23ca1 --- /dev/null +++ b/src/langbot/pkg/skill/__init__.py @@ -0,0 +1,3 @@ +from .manager import SkillManager + +__all__ = ['SkillManager'] diff --git a/src/langbot/pkg/skill/activation.py b/src/langbot/pkg/skill/activation.py new file mode 100644 index 000000000..706747060 --- /dev/null +++ b/src/langbot/pkg/skill/activation.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import typing + +from ..provider.tools.loaders import skill as skill_loader + +if typing.TYPE_CHECKING: + from ..core import app + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +# Skill activation is now handled through Tool Call mechanism (activate tool). +# This file is kept for potential future extensions but the text marker +# detection mechanism has been removed. + + +def register_activated_skill( + ap: app.Application, + query: pipeline_query.Query, + skill_name: str, +) -> bool: + """Register an activated skill for sandbox mount path resolution. + + This is called by the activate tool when a skill is activated via Tool Call. + """ + skill_mgr = getattr(ap, 'skill_mgr', None) + if skill_mgr is None: + return False + + skill_data = skill_mgr.get_skill_by_name(skill_name) + if skill_data is None: + return False + + skill_loader.register_activated_skill(query, skill_data) + return True diff --git a/src/langbot/pkg/skill/manager.py b/src/langbot/pkg/skill/manager.py new file mode 100644 index 000000000..ddb2125c3 --- /dev/null +++ b/src/langbot/pkg/skill/manager.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import os +import typing + +from ..core import app + +if typing.TYPE_CHECKING: + pass + + +class SkillManager: + """Skill manager backed by Box-managed or local filesystem packages. + + In sandbox deployments, skills are loaded from the Box runtime. Local + data/skills remains as the fallback for non-Box development. + + Skills are activated through the `activate` tool (Tool Call mechanism), + aligned with Claude Code's design. This protects KV Cache and follows + industry standard. + """ + + ap: app.Application + skills: dict[str, dict] + + def __init__(self, ap: app.Application): + self.ap = ap + self.skills = {} + + async def initialize(self): + await self.reload_skills() + + async def reload_skills(self): + """Reload all skills from the Box runtime. + + Box is the only source of truth for skills. When Box is unavailable + (disabled in config or unreachable) the cache is emptied — there is + no local filesystem fallback. Skills whose ``package_root`` is no + longer visible on the LangBot-side filesystem are dropped so they + don't surface as stale ``extra_mounts``. + """ + self.skills = {} + + box_service = getattr(self.ap, 'box_service', None) + if box_service is None or not getattr(box_service, 'available', False): + self.ap.logger.info('Box runtime unavailable; skill cache is empty.') + return + + # LangBot may only validate Box-reported paths against its own + # filesystem when the two share one (local stdio mode). In separated + # deployments (Docker Compose, k8s sidecar, --standalone-box, remote + # endpoint) the package_root lives on the Box runtime's filesystem and + # is not resolvable here, so we trust what Box reports. + validate_locally = bool(getattr(box_service, 'shares_filesystem_with_box', False)) + + try: + dropped = 0 + for skill_data in await box_service.list_skills(): + skill_name = skill_data.get('name') + if not skill_name: + continue + package_root = str(skill_data.get('package_root', '') or '').strip() + if validate_locally and package_root and not os.path.isdir(package_root): + self.ap.logger.warning( + f'Skill "{skill_name}" reported by Box runtime but ' + f'package_root missing on LangBot filesystem ' + f'({package_root}); dropping from in-memory cache.' + ) + dropped += 1 + continue + self.skills[skill_name] = skill_data + if dropped: + self.ap.logger.warning( + f'Loaded {len(self.skills)} skills from Box runtime ' + f'({dropped} dropped due to missing package_root).' + ) + else: + self.ap.logger.info(f'Loaded {len(self.skills)} skills from Box runtime') + except Exception as exc: + self.ap.logger.warning(f'Failed to load skills from Box runtime: {exc}') + + def refresh_skill_from_disk(self, skill_name: str) -> bool: + """Confirm a single skill is present in the cache. + + With Box as the only source of truth, the actual reload is driven by + SkillService callers awaiting ``reload_skills``; this method only + reports whether the cache still has the skill. + """ + if not skill_name: + return False + return skill_name in self.skills + + def get_skill_by_name(self, name: str) -> dict | None: + """Get skill data by name.""" + return self.skills.get(name) + + def get_skill_index(self, bound_skills: list[str] | None = None) -> str: + """Render the pipeline-visible skills as a short ``name: description`` + index suitable for the system prompt. + + ``bound_skills`` follows the same convention as + ``query.variables['_pipeline_bound_skills']``: ``None`` means every + loaded skill is exposed; an explicit list filters to that subset. + Returns an empty string when no skills are visible. + """ + lines: list[str] = [] + for skill in self.skills.values(): + name = skill.get('name') + if not name: + continue + if bound_skills is not None and name not in bound_skills: + continue + display = skill.get('display_name') or name + description = (skill.get('description') or '').strip().replace('\n', ' ') + lines.append(f'- {name} ({display}): {description}') + + if not lines: + return '' + return 'Available Skills:\n' + '\n'.join(lines) + + def build_skill_aware_prompt_addition(self, bound_skills: list[str] | None = None) -> str: + """Build the system-prompt addendum that makes the LLM aware of the + pipeline-visible skills. + + Only metadata (name + description) is injected — the full SKILL.md is + loaded later via the ``activate`` Tool Call, protecting KV cache and + matching Claude Code's progressive disclosure pattern. Returns an + empty string when no skills are visible (no prompt change at all). + """ + skill_index = self.get_skill_index(bound_skills) + if not skill_index: + return '' + return ( + '\n\n' + f'{skill_index}\n\n' + "When the user's request clearly matches one or more skills " + 'based on their descriptions above, call the `activate` tool with ' + 'the skill name to load its full instructions. Only the name and ' + 'description are visible here; the actual instructions arrive as ' + 'the tool result. If no skill is a clear match, respond normally ' + 'without activating any skill.' + ) diff --git a/src/langbot/pkg/skill/utils.py b/src/langbot/pkg/skill/utils.py new file mode 100644 index 000000000..fc143362f --- /dev/null +++ b/src/langbot/pkg/skill/utils.py @@ -0,0 +1,37 @@ +"""Shared utilities for skill file parsing.""" + +import yaml + + +def parse_frontmatter(content: str) -> tuple[dict, str]: + """Parse YAML frontmatter from markdown content. + + Expects format: + --- + name: my-skill + description: Does something + --- + # Actual instructions... + + Returns: + Tuple of (metadata dict, remaining content) + """ + if not content.startswith('---'): + return {}, content + + parts = content.split('---', 2) + if len(parts) < 3: + return {}, content + + frontmatter_str = parts[1].strip() + instructions = parts[2].strip() + + try: + metadata = yaml.safe_load(frontmatter_str) or {} + except yaml.YAMLError: + metadata = {} + + if not isinstance(metadata, dict): + metadata = {} + + return metadata, instructions diff --git a/src/langbot/pkg/survey/manager.py b/src/langbot/pkg/survey/manager.py index 0e554050d..34689625e 100644 --- a/src/langbot/pkg/survey/manager.py +++ b/src/langbot/pkg/survey/manager.py @@ -13,6 +13,11 @@ from ..entity.persistence.metadata import Metadata from ..utils import constants SURVEY_TRIGGERED_KEY = 'survey_triggered_events' +BOT_RESPONSE_COUNT_KEY = 'survey_bot_response_count' + +# Milestone event fired when an instance accumulates this many successful bot responses +BOT_RESPONSE_MILESTONE = 100 +BOT_RESPONSE_MILESTONE_EVENT = f'bot_response_success_{BOT_RESPONSE_MILESTONE}' class SurveyManager: @@ -23,11 +28,13 @@ class SurveyManager: self._triggered_events: set[str] = set() self._pending_survey: typing.Optional[dict] = None self._space_url: str = '' + self._bot_response_count: int = 0 async def initialize(self): space_config = self.ap.instance_config.data.get('space', {}) self._space_url = space_config.get('url', '').rstrip('/') await self._load_triggered_events() + await self._load_bot_response_count() async def _load_triggered_events(self): """Load previously triggered events from metadata table.""" @@ -65,6 +72,54 @@ class SurveyManager: return False return bool(self._space_url) + async def _load_bot_response_count(self): + """Load the persisted successful bot response count from metadata table.""" + try: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(Metadata).where(Metadata.key == BOT_RESPONSE_COUNT_KEY) + ) + row = result.first() + if row: + self._bot_response_count = int(row[0].value) + except Exception: + self._bot_response_count = 0 + + async def _save_bot_response_count(self): + """Persist the successful bot response count to metadata table.""" + try: + value = str(self._bot_response_count) + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(Metadata).where(Metadata.key == BOT_RESPONSE_COUNT_KEY) + ) + if result.first(): + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(Metadata).where(Metadata.key == BOT_RESPONSE_COUNT_KEY).values(value=value) + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(Metadata).values(key=BOT_RESPONSE_COUNT_KEY, value=value) + ) + except Exception as e: + self.ap.logger.debug(f'Failed to save survey bot response count: {e}') + + async def record_bot_response_success(self): + """Count a successful bot response; fires the milestone event at the threshold. + + Called by the chat handler after each successful (non-WebSocket) response. + The count is persisted so it survives restarts. Once the milestone event + has been triggered, counting stops (no further writes needed). + """ + if BOT_RESPONSE_MILESTONE_EVENT in self._triggered_events: + return + if not self._is_space_configured(): + return + + self._bot_response_count += 1 + await self._save_bot_response_count() + + if self._bot_response_count >= BOT_RESPONSE_MILESTONE: + await self.trigger_event(BOT_RESPONSE_MILESTONE_EVENT) + async def trigger_event(self, event: str): """Called when an event occurs. Checks Space for a pending survey.""" if event in self._triggered_events: @@ -104,6 +159,21 @@ class SurveyManager: """Clear the pending survey (after user responds or dismisses).""" self._pending_survey = None + async def _build_base_metadata(self, user_email: str | None = None) -> dict: + metadata = { + 'version': constants.semantic_version, + 'instance_id': constants.instance_id, + } + if user_email: + metadata['login_account'] = user_email + try: + user_obj = await self.ap.user_service.get_user_by_email(user_email) + metadata['account_type'] = getattr(user_obj, 'account_type', '') or 'local' + metadata['space_account_uuid'] = getattr(user_obj, 'space_account_uuid', '') or '' + except Exception: + pass + return metadata + async def submit_response(self, survey_id: str, answers: dict, completed: bool = True) -> bool: """Submit a survey response to Space.""" if not self._is_space_configured(): @@ -114,9 +184,7 @@ class SurveyManager: 'survey_id': survey_id, 'instance_id': constants.instance_id, 'answers': answers, - 'metadata': { - 'version': constants.semantic_version, - }, + 'metadata': await self._build_base_metadata(), 'completed': completed, } async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client: @@ -128,6 +196,33 @@ class SurveyManager: self.ap.logger.warning(f'Failed to submit survey response: {e}') return False + async def submit_feedback( + self, + content: str, + attachments: list[dict], + user_email: str | None = None, + ) -> bool: + """Submit an on-demand user feedback item to Space.""" + if not self._is_space_configured(): + return False + try: + url = f'{self._space_url}/api/v1/survey/feedback' + metadata = await self._build_base_metadata(user_email) + payload = { + 'instance_id': constants.instance_id, + 'content': content, + 'attachments': attachments, + 'metadata': metadata, + } + async with httpx.AsyncClient(timeout=httpx.Timeout(30)) as client: + resp = await client.post(url, json=payload) + if resp.status_code == 200: + return True + self.ap.logger.warning(f'Failed to submit feedback: {resp.status_code} {resp.text[:200]}') + except Exception as e: + self.ap.logger.warning(f'Failed to submit feedback: {e}') + return False + async def dismiss_survey(self, survey_id: str) -> bool: """Dismiss a survey.""" if not self._is_space_configured(): diff --git a/src/langbot/pkg/telemetry/features.py b/src/langbot/pkg/telemetry/features.py new file mode 100644 index 000000000..ab66f9884 --- /dev/null +++ b/src/langbot/pkg/telemetry/features.py @@ -0,0 +1,102 @@ +"""Per-query telemetry feature counters. + +Collects anonymous, content-free usage signals (tool call counts, knowledge +base usage, sandbox executions, ...) into ``query.variables`` during query +processing. The chat handler reads the accumulated dict when building the +telemetry payload and ships it as the ``features`` JSON object. + +Every helper here is defensive: telemetry must NEVER break the pipeline, so +all mutations are wrapped and failures are silently ignored. +""" + +from __future__ import annotations + +import typing + +if typing.TYPE_CHECKING: + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +FEATURES_KEY = '_telemetry_features' + + +def get_features(query: pipeline_query.Query) -> dict: + """Return the mutable features dict for this query, creating it if needed.""" + try: + return query.variables.setdefault(FEATURES_KEY, {}) + except Exception: + return {} + + +def increment(query: pipeline_query.Query, group: str, key: str | None = None, amount: int = 1) -> None: + """Increment a counter. + + ``increment(q, 'sandbox', 'execs')`` -> features['sandbox']['execs'] += 1 + ``increment(q, 'tool_call_rounds')`` -> features['tool_call_rounds'] += 1 + """ + try: + features = get_features(query) + if key is None: + features[group] = int(features.get(group, 0)) + amount + else: + nested = features.setdefault(group, {}) + if isinstance(nested, dict): + nested[key] = int(nested.get(key, 0)) + amount + except Exception: + pass + + +def set_value(query: pipeline_query.Query, group: str, value: typing.Any) -> None: + """Set a feature value (overwrites).""" + try: + get_features(query)[group] = value + except Exception: + pass + + +def collect_features(query: pipeline_query.Query) -> dict: + """Build the final ``features`` object for the telemetry payload. + + Combines the counters accumulated during processing with end-of-query + snapshots (activated skills, bound MCP servers). Returns a plain dict + that must be JSON-serializable; non-serializable values are dropped. + """ + features: dict = {} + try: + accumulated = query.variables.get(FEATURES_KEY) + if isinstance(accumulated, dict): + features.update(accumulated) + except Exception: + pass + + # Activated skills (names only, registered by the activate tool) + try: + activated = query.variables.get('_activated_skills', {}) + if isinstance(activated, dict) and activated: + features['activated_skills'] = sorted(activated.keys()) + except Exception: + pass + + # MCP servers bound to the pipeline (names only; None means "all enabled") + try: + bound_mcp = query.variables.get('_pipeline_bound_mcp_servers', None) + if bound_mcp is not None: + features['mcp_servers'] = list(bound_mcp) + except Exception: + pass + + # Drop anything that is not JSON-serializable + import json + + try: + json.dumps(features) + return features + except Exception: + safe: dict = {} + for k, v in features.items(): + try: + json.dumps({k: v}) + safe[k] = v + except Exception: + continue + return safe diff --git a/src/langbot/pkg/telemetry/heartbeat.py b/src/langbot/pkg/telemetry/heartbeat.py new file mode 100644 index 000000000..34b616733 --- /dev/null +++ b/src/langbot/pkg/telemetry/heartbeat.py @@ -0,0 +1,132 @@ +"""Instance heartbeat telemetry. + +Sends a periodic (startup + daily) anonymous snapshot of the instance's +configuration profile so feature *adoption* can be measured separately from +feature *usage* (which is covered by per-query telemetry). + +The snapshot contains only configuration categories and object counts — +never names of user resources (except adapter type names, which are LangBot +adapter identifiers, not account info), never message content, never +credentials. +""" + +from __future__ import annotations + +import asyncio +import typing +from datetime import datetime, timezone + +import sqlalchemy + +from ..utils import constants, platform as platform_utils + +if typing.TYPE_CHECKING: + from ..core import app as core_app + + +HEARTBEAT_INTERVAL_SECONDS = 24 * 3600 + + +async def _count(ap: core_app.Application, table) -> int: + """Count rows in a persistence table; -1 when unavailable.""" + try: + result = await ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.func.count()).select_from(table)) + return int(result.scalar() or 0) + except Exception: + return -1 + + +async def build_heartbeat_payload(ap: core_app.Application) -> dict: + """Collect the anonymous instance profile snapshot.""" + from ..entity.persistence import bot as persistence_bot + from ..entity.persistence import mcp as persistence_mcp + from ..entity.persistence import pipeline as persistence_pipeline + from ..entity.persistence import rag as persistence_rag + + config = ap.instance_config.data if ap.instance_config else {} + + features: dict = { + 'deploy_platform': platform_utils.get_platform(), + 'database': config.get('database', {}).get('use', 'sqlite'), + 'vdb': config.get('vdb', {}).get('use', 'chroma'), + } + + # Box / sandbox profile + try: + box_service = getattr(ap, 'box_service', None) + if box_service is not None: + box_info: dict = { + 'enabled': bool(box_service.enabled), + 'available': bool(box_service.available), + } + box_cfg = config.get('box', {}) + box_info['backend'] = box_cfg.get('backend', 'local') + try: + box_info['shares_fs'] = bool(box_service.shares_filesystem_with_box) + except Exception: + pass + features['box'] = box_info + except Exception: + pass + + # Bots / adapters (adapter type names only) + try: + platform_mgr = getattr(ap, 'platform_mgr', None) + if platform_mgr is not None and getattr(platform_mgr, 'bots', None) is not None: + enabled_bots = [bot for bot in platform_mgr.bots if getattr(bot, 'enable', False)] + features['bot_count'] = len(platform_mgr.bots) + adapters = sorted({bot.adapter.__class__.__name__ for bot in enabled_bots if getattr(bot, 'adapter', None)}) + features['adapters'] = adapters + except Exception: + pass + + # Resource counts + features['pipeline_count'] = await _count(ap, persistence_pipeline.LegacyPipeline) + features['mcp_server_count'] = await _count(ap, persistence_mcp.MCPServer) + features['knowledge_base_count'] = await _count(ap, persistence_rag.KnowledgeBase) + if 'bot_count' not in features: + features['bot_count'] = await _count(ap, persistence_bot.Bot) + + # Plugin count (from plugin runtime) + try: + plugin_connector = getattr(ap, 'plugin_connector', None) + if plugin_connector is not None: + plugins = await plugin_connector.list_plugins() + features['plugin_count'] = len(plugins) + except Exception: + features['plugin_count'] = -1 + + # Skill count (from Box runtime via skill manager) + try: + skill_mgr = getattr(ap, 'skill_mgr', None) + if skill_mgr is not None and getattr(skill_mgr, 'skills', None) is not None: + features['skill_count'] = len(skill_mgr.skills) + except Exception: + pass + + return { + 'event_type': 'instance_heartbeat', + 'query_id': '', + 'version': constants.semantic_version, + 'instance_id': constants.instance_id, + 'instance_create_ts': constants.instance_create_ts, + 'edition': constants.edition, + 'features': features, + 'timestamp': datetime.now(timezone.utc).isoformat(), + } + + +async def heartbeat_loop(ap: core_app.Application) -> None: + """Send one heartbeat shortly after startup, then daily.""" + # Small delay so managers (platform, skills, plugins) finish loading first + await asyncio.sleep(30) + while True: + try: + payload = await build_heartbeat_payload(ap) + await ap.telemetry.start_send_task(payload) + except Exception as e: + try: + ap.logger.debug(f'Telemetry heartbeat failed: {e}') + except Exception: + pass + await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS) diff --git a/src/langbot/pkg/telemetry/telemetry.py b/src/langbot/pkg/telemetry/telemetry.py index 041529018..2948e268a 100644 --- a/src/langbot/pkg/telemetry/telemetry.py +++ b/src/langbot/pkg/telemetry/telemetry.py @@ -68,10 +68,21 @@ class TelemetryManager: 'edition', 'error', 'timestamp', + 'event_type', ): + if sfield not in sanitized: + continue v = sanitized.get(sfield) sanitized[sfield] = '' if v is None else str(v) + # event_type defaults to 'query' for backward compatibility + if not sanitized.get('event_type'): + sanitized['event_type'] = 'query' + + # features must be a JSON object + if 'features' in sanitized and not isinstance(sanitized['features'], dict): + sanitized['features'] = {} + if 'duration_ms' in sanitized: try: sanitized['duration_ms'] = ( diff --git a/src/langbot/pkg/utils/constants.py b/src/langbot/pkg/utils/constants.py index 4fad9069b..2af16486f 100644 --- a/src/langbot/pkg/utils/constants.py +++ b/src/langbot/pkg/utils/constants.py @@ -3,10 +3,24 @@ import langbot semantic_version = f'v{langbot.__version__}' required_database_version = 25 -"""Tag the version of the database schema, used to check if the database needs to be migrated""" +"""Tag the version of the legacy (3.x) database schema migration chain. + +Frozen at 25: the legacy ``pkg/persistence/migrations`` system (DBMigration / +dbmXXX_*.py) is deprecated and no longer accepts new migrations. All schema +changes from here on are managed by Alembic (see +``pkg/persistence/alembic/versions``). This value only gates the one-time +upgrade of pre-existing 3.x databases up to the Alembic baseline.""" debug_mode = False edition = 'community' instance_id = '' + +instance_create_ts = 0 +"""Unix timestamp (seconds) of when this instance was first created. + +Sourced from ``data/labels/instance_id.json``. Backfilled to the current +time for instances created before this field existed, so it is always a +positive value once load_config has run. +""" diff --git a/src/langbot/pkg/utils/managed_runtime.py b/src/langbot/pkg/utils/managed_runtime.py new file mode 100644 index 000000000..77f59be4c --- /dev/null +++ b/src/langbot/pkg/utils/managed_runtime.py @@ -0,0 +1,88 @@ +"""Base class for connectors that may manage a local runtime subprocess.""" + +from __future__ import annotations + +import asyncio +import os +import sys +from typing import TYPE_CHECKING, Awaitable, Callable + +if TYPE_CHECKING: + from ..core import app as core_app + + +class ManagedRuntimeConnector: + """Base class for connectors that may manage a local runtime subprocess. + + Provides shared lifecycle helpers: subprocess launch, health-check retry, + and graceful termination. Concrete connectors (plugin, box, …) inherit + this and add their own protocol-specific logic. + """ + + ap: 'core_app.Application' + runtime_subprocess: asyncio.subprocess.Process | None + runtime_subprocess_task: asyncio.Task | None + + def __init__(self, ap: 'core_app.Application'): + self.ap = ap + self.runtime_subprocess = None + self.runtime_subprocess_task = None + + async def _start_runtime_subprocess(self, *args: str) -> None: + """Launch a local runtime as a subprocess of the current Python interpreter. + + If a subprocess is already running (no *returncode* yet), this is a no-op. + """ + if self.runtime_subprocess is not None and self.runtime_subprocess.returncode is None: + return + + python_path = sys.executable + env = os.environ.copy() + self.runtime_subprocess = await asyncio.create_subprocess_exec( + python_path, + *args, + env=env, + ) + self.runtime_subprocess_task = asyncio.create_task(self.runtime_subprocess.wait()) + + async def _wait_until_ready( + self, + check: Callable[[], Awaitable[None]], + retries: int = 40, + interval: float = 0.25, + runtime_name: str = 'runtime', + ) -> None: + """Repeatedly call *check* until it succeeds or retries are exhausted. + + Between attempts the method sleeps for *interval* seconds. If the + managed subprocess exits before readiness is confirmed, a + ``RuntimeError`` is raised immediately. + """ + last_exc: Exception | None = None + for _ in range(retries): + # Fast-fail if the process already died. + if self.runtime_subprocess is not None and self.runtime_subprocess.returncode is not None: + raise RuntimeError( + f'local {runtime_name} exited before becoming ready (code {self.runtime_subprocess.returncode})' + ) + + try: + await check() + return + except Exception as exc: + last_exc = exc + await asyncio.sleep(interval) + + if last_exc is not None: + raise last_exc + raise RuntimeError(f'local {runtime_name} did not become ready') + + def _dispose_subprocess(self) -> None: + """Terminate the managed subprocess and cancel its wait task.""" + if self.runtime_subprocess is not None and self.runtime_subprocess.returncode is None: + self.ap.logger.info('Terminating managed runtime process...') + self.runtime_subprocess.terminate() + + if self.runtime_subprocess_task is not None: + self.runtime_subprocess_task.cancel() + self.runtime_subprocess_task = None diff --git a/src/langbot/pkg/utils/paths.py b/src/langbot/pkg/utils/paths.py index fd052c507..6f95ec82b 100644 --- a/src/langbot/pkg/utils/paths.py +++ b/src/langbot/pkg/utils/paths.py @@ -1,37 +1,70 @@ -"""Utility functions for finding package resources""" +"""Utility functions for finding package resources and runtime data roots.""" import os from pathlib import Path _is_source_install = None +_source_root = None + + +def _find_source_root() -> Path | None: + """Locate the LangBot repository root when running from source.""" + global _source_root + + if _source_root is not None: + return _source_root + + current = Path(__file__).resolve() + for parent in current.parents: + if (parent / 'pyproject.toml').exists() and (parent / 'main.py').exists(): + _source_root = parent + return parent + + _source_root = None + return None def _check_if_source_install() -> bool: """ - Check if we're running from source directory or an installed package. - Cached to avoid repeated file I/O. + Check if we're running from the LangBot source tree. + Cached to avoid repeated filesystem scans. """ global _is_source_install if _is_source_install is not None: return _is_source_install - # Check if main.py exists in current directory with LangBot marker - if os.path.exists('main.py'): - try: - with open('main.py', 'r', encoding='utf-8') as f: - # Only read first 500 chars to check for marker - content = f.read(500) - if 'LangBot/main.py' in content: - _is_source_install = True - return True - except (IOError, OSError, UnicodeDecodeError): - # If we can't read the file, assume not a source install - pass + _is_source_install = _find_source_root() is not None + return _is_source_install - _is_source_install = False - return False + +def get_data_root() -> str: + """ + Get the runtime data root. + + Priority: + 1. LANGBOT_DATA_ROOT environment override + 2. Source checkout root /data when running from source + 3. Current working directory /data for installed-package usage + """ + env_root = os.environ.get('LANGBOT_DATA_ROOT', '').strip() + if env_root: + return str(Path(env_root).expanduser().resolve()) + + source_root = _find_source_root() + if source_root is not None: + return str((source_root / 'data').resolve()) + + return str((Path.cwd() / 'data').resolve()) + + +def get_data_path(*parts: str) -> str: + """Join path segments under the resolved data root.""" + data_root = Path(get_data_root()) + if not parts: + return str(data_root) + return str((data_root.joinpath(*parts)).resolve()) def get_frontend_path() -> str: @@ -76,8 +109,11 @@ def get_resource_path(resource: str) -> str: Absolute path to the resource """ # First, check if resource exists in current directory (source install) - if _check_if_source_install() and os.path.exists(resource): - return resource + source_root = _find_source_root() + if source_root is not None: + source_resource = source_root / resource + if source_resource.exists(): + return str(source_resource) # Second, check current directory anyway if os.path.exists(resource): diff --git a/src/langbot/pkg/utils/platform.py b/src/langbot/pkg/utils/platform.py index b3f7a6df9..9badb42ee 100644 --- a/src/langbot/pkg/utils/platform.py +++ b/src/langbot/pkg/utils/platform.py @@ -16,7 +16,14 @@ def get_platform() -> str: standalone_runtime = False +standalone_box = False + def use_websocket_to_connect_plugin_runtime() -> bool: """是否使用 websocket 连接插件运行时""" return standalone_runtime + + +def use_websocket_to_connect_box_runtime() -> bool: + """Whether to use WebSocket to connect to an external box runtime.""" + return standalone_box diff --git a/src/langbot/pkg/utils/version.py b/src/langbot/pkg/utils/version.py index 23440e4a9..1e19420db 100644 --- a/src/langbot/pkg/utils/version.py +++ b/src/langbot/pkg/utils/version.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import typing import logging @@ -11,7 +10,7 @@ from . import constants class VersionManager: - """版本管理器""" + """Version manager""" ap: app.Application @@ -22,190 +21,68 @@ class VersionManager: pass def get_current_version(self) -> str: - current_tag = constants.semantic_version - - return current_tag + return constants.semantic_version async def get_release_list(self) -> list: - """获取发行列表""" + """Fetch release list from Space API (cached GitHub releases).""" try: rls_list_resp = requests.get( - url='https://api.github.com/repos/langbot-app/LangBot/releases', + url='https://space.langbot.app/api/v1/dist/info/releases', proxies=self.ap.proxy_mgr.get_forward_proxies(), - timeout=5, + timeout=10, ) - rls_list_resp.raise_for_status() # 检查请求是否成功 - rls_list = rls_list_resp.json() - return rls_list + rls_list_resp.raise_for_status() + resp_json = rls_list_resp.json() + if resp_json.get('code') == 0 and isinstance(resp_json.get('data'), list): + return resp_json['data'] + self.ap.logger.warning(f'Failed to fetch release list: unexpected response: {resp_json.get("msg", "")}') + return [] except Exception as e: - self.ap.logger.warning(f'获取发行列表失败: {e}') - pass + self.ap.logger.warning(f'Failed to fetch release list: {e}') return [] - async def update_all(self): - """检查更新并下载源码""" - - current_tag = self.get_current_version() - - rls_list = await self.get_release_list() - - latest_rls = {} - rls_notes = [] - latest_tag_name = '' - for rls in rls_list: - rls_notes.append(rls['name']) # 使用发行名称作为note - if latest_tag_name == '': - latest_tag_name = rls['tag_name'] - - if rls['tag_name'] == current_tag: - break - - if latest_rls == {}: - latest_rls = rls - self.ap.logger.info('更新日志: {}'.format(rls_notes)) - - if latest_rls == {} and not self.is_newer(latest_tag_name, current_tag): # 没有新版本 - return False - - # 下载最新版本的zip到temp目录 - self.ap.logger.info('开始下载最新版本: {}'.format(latest_rls['zipball_url'])) - - zip_url = latest_rls['zipball_url'] - zip_resp = requests.get(url=zip_url, proxies=self.ap.proxy_mgr.get_forward_proxies()) - zip_data = zip_resp.content - - # 检查temp/updater目录 - if not os.path.exists('temp'): - os.mkdir('temp') - if not os.path.exists('temp/updater'): - os.mkdir('temp/updater') - with open('temp/updater/{}.zip'.format(latest_rls['tag_name']), 'wb') as f: - f.write(zip_data) - - self.ap.logger.info('下载最新版本完成: {}'.format('temp/updater/{}.zip'.format(latest_rls['tag_name']))) - - # 解压zip到temp/updater// - import zipfile - - # 检查目标文件夹 - if os.path.exists('temp/updater/{}'.format(latest_rls['tag_name'])): - import shutil - - shutil.rmtree('temp/updater/{}'.format(latest_rls['tag_name'])) - os.mkdir('temp/updater/{}'.format(latest_rls['tag_name'])) - with zipfile.ZipFile('temp/updater/{}.zip'.format(latest_rls['tag_name']), 'r') as zip_ref: - zip_ref.extractall('temp/updater/{}'.format(latest_rls['tag_name'])) - - # 覆盖源码 - source_root = '' - # 找到temp/updater//中的第一个子目录路径 - for root, dirs, files in os.walk('temp/updater/{}'.format(latest_rls['tag_name'])): - if root != 'temp/updater/{}'.format(latest_rls['tag_name']): - source_root = root - break - - # 覆盖源码 - import shutil - - for root, dirs, files in os.walk(source_root): - # 覆盖所有子文件子目录 - for file in files: - src = os.path.join(root, file) - dst = src.replace(source_root, '.') - if os.path.exists(dst): - os.remove(dst) - - # 检查目标文件夹是否存在 - if not os.path.exists(os.path.dirname(dst)): - os.makedirs(os.path.dirname(dst)) - # 检查目标文件是否存在 - if not os.path.exists(dst): - # 创建目标文件 - open(dst, 'w').close() - - shutil.copy(src, dst) - - # 把current_tag写入文件 - current_tag = latest_rls['tag_name'] - with open('current_tag', 'w') as f: - f.write(current_tag) - - # TODO statistics - async def is_new_version_available(self) -> bool: - """检查是否有新版本""" - # 从github获取release列表 + """Check whether a newer version is available.""" rls_list = await self.get_release_list() - if rls_list is None: + if not rls_list: return False - # 获取当前版本 current_tag = self.get_current_version() - # 检查是否有新版本 latest_tag_name = '' for rls in rls_list: - if latest_tag_name == '': - latest_tag_name = rls['tag_name'] - break + latest_tag_name = rls.get('tag_name', '') + break - return self.is_newer(latest_tag_name, current_tag) + return self._is_newer(latest_tag_name, current_tag) - def is_newer(self, new_tag: str, old_tag: str): - """判断版本是否更新,忽略第四位版本和第一位版本""" - if new_tag == old_tag: + def _is_newer(self, new_tag: str, old_tag: str) -> bool: + """Check if new_tag is a newer version than old_tag. + + Compares the first three segments (major.minor.patch) only. + Returns False if the major version differs (breaking change boundary). + """ + if not new_tag or not old_tag or new_tag == old_tag: return False - new_tag = new_tag.split('.') - old_tag = old_tag.split('.') + new_parts = new_tag.split('.') + old_parts = old_tag.split('.') - # 判断主版本是否相同 - if new_tag[0] != old_tag[0]: + # Different major version — not considered an upgrade + if new_parts[0] != old_parts[0]: return False - if len(new_tag) < 4: + if len(new_parts) < 4: return True - # 合成前三段,判断是否相同 - new_tag = '.'.join(new_tag[:3]) - old_tag = '.'.join(old_tag[:3]) - - return new_tag != old_tag - - def compare_version_str(v0: str, v1: str) -> int: - """比较两个版本号""" - - # 删除版本号前的v - if v0.startswith('v'): - v0 = v0[1:] - if v1.startswith('v'): - v1 = v1[1:] - - v0: list = v0.split('.') - v1: list = v1.split('.') - - # 如果两个版本号节数不同,把短的后面用0补齐 - if len(v0) < len(v1): - v0.extend(['0'] * (len(v1) - len(v0))) - elif len(v0) > len(v1): - v1.extend(['0'] * (len(v0) - len(v1))) - - # 从高位向低位比较 - for i in range(len(v0)): - if int(v0[i]) > int(v1[i]): - return 1 - elif int(v0[i]) < int(v1[i]): - return -1 - - return 0 + return '.'.join(new_parts[:3]) != '.'.join(old_parts[:3]) async def show_version_update(self) -> typing.Tuple[str, int]: try: - if await self.ap.ver_mgr.is_new_version_available(): + if await self.is_new_version_available(): return ( - 'New version available:\n有新版本可用,根据文档更新: \nhttps://link.langbot.app/zh/docs/update', + 'New version available. Update guide: https://link.langbot.app/en/docs/update', logging.INFO, ) - except Exception as e: return f'Error checking version update: {e}', logging.WARNING diff --git a/src/langbot/pkg/vector/mgr.py b/src/langbot/pkg/vector/mgr.py index 73ed5d747..765c259f9 100644 --- a/src/langbot/pkg/vector/mgr.py +++ b/src/langbot/pkg/vector/mgr.py @@ -33,6 +33,12 @@ class VectorDBManager: self.vector_db = SeekDBVectorDatabase(self.ap) self.ap.logger.info('Initialized SeekDB vector database backend.') + elif vdb_type == 'valkey_search': + from .vdbs.valkey_search import ValkeySearchVectorDatabase + + self.vector_db = ValkeySearchVectorDatabase(self.ap) + self.ap.logger.info('Initialized Valkey Search vector database backend.') + elif vdb_type == 'milvus': from .vdbs.milvus import MilvusVectorDatabase diff --git a/src/langbot/pkg/vector/vdbs/valkey_search.py b/src/langbot/pkg/vector/vdbs/valkey_search.py new file mode 100644 index 000000000..9f92acc97 --- /dev/null +++ b/src/langbot/pkg/vector/vdbs/valkey_search.py @@ -0,0 +1,828 @@ +from __future__ import annotations + +import asyncio +import json +import struct +from typing import Any + +from langbot.pkg.core import app +from langbot.pkg.vector.vdb import VectorDatabase, SearchType +from langbot.pkg.vector.filter_utils import normalize_filter, strip_unsupported_fields + +try: + from glide import ( + Batch, + GlideClient, + GlideClientConfiguration, + NodeAddress, + RequestError, + ServerCredentials, + ft, + VectorField, + VectorFieldAttributesHnsw, + VectorFieldAttributesFlat, + VectorAlgorithm, + VectorType, + DistanceMetricType, + TagField, + TextField, + FtCreateOptions, + DataType, + FtSearchOptions, + FtSearchLimit, + ReturnField, + ) + + VALKEY_SEARCH_AVAILABLE = True +except ImportError: + VALKEY_SEARCH_AVAILABLE = False + +# Default per-request timeout (ms) for the glide client. The glide library +# default is 250ms, which is too low for vector KNN (``FT.SEARCH ... =>[KNN]``) +# under moderate load or with large indexes and yields spurious TimeoutErrors. +# Overridable via the ``vdb.valkey_search.request_timeout`` config option. +_DEFAULT_REQUEST_TIMEOUT_MS = 5000 + +# Safety cap on the number of SCAN rounds when purging a collection's keys, so +# a cursor-handling bug or pathological keyspace can never spin forever. +_MAX_SCAN_ROUNDS = 100000 + + +# Mandatory client name for production observability (CLIENT LIST / dashboards). +VALKEY_CLIENT_NAME = 'langbot_vector_client' + +# Fixed, indexed metadata schema. LangBot's RAG layer stores ``file_id`` on +# every chunk; it is the only metadata field we promote to a first-class +# (filterable) index field. All other metadata is preserved verbatim inside +# the ``metadata_json`` field so it survives a round-trip, but is NOT +# filterable (the established Milvus / pgvector pragmatism). +_INDEXED_TAG_FIELDS = {'file_id'} +_SUPPORTED_FILTER_FIELDS = set(_INDEXED_TAG_FIELDS) + +# Hash field names used for stored documents. +_FIELD_VECTOR = 'vector' +_FIELD_DOCUMENT = 'document' +_FIELD_FILE_ID = 'file_id' +_FIELD_METADATA = 'metadata_json' +_VEC_SCORE_ALIAS = '__vec_score' + +# Valkey Search has no bare "match everything" token for non-vector queries +# (a standalone ``*`` is a syntax error). A negated match on a sentinel tag +# value that can never exist matches every key, which is the canonical +# match-all idiom for FT.SEARCH. +_MATCH_ALL = '-@file_id:{__langbot_match_all_sentinel__}' + +# Page size used when enumerating matching keys for deletion. Deletes +# paginate through the full result set in batches of this size so that +# files/filters matching more than one page of chunks are fully removed +# (no silent truncation / orphaned vectors). +_DELETE_SCAN_BATCH = 10000 + +# Characters Valkey Search's TAG query parser cannot handle even when +# backslash-escaped (the brace delimiters and the wildcard). file_id TAG +# values are percent-encoded over this set (plus '%' itself, so the encoding +# is reversible/unambiguous) before being stored or queried, so an arbitrary +# file_id round-trips instead of producing an unparseable query. For normal +# UUID/hash file_ids none of these characters occur, so the encoding is a +# no-op and the stored value is unchanged. The original file_id is always +# preserved verbatim inside ``metadata_json``. +_FT_UNSAFE_TAG_CHARS = frozenset('{}*%') + + +class ValkeySearchVectorDatabase(VectorDatabase): + """Valkey Search (valkey-bundle) vector database adapter for LangBot. + + Backed by the Valkey Search module shipped in ``valkey/valkey-bundle``, + accessed through the official ``valkey-glide`` client's native ``ft`` + (search) command namespace. Documents are stored as Valkey HASH keys + under a per-collection prefix and indexed by one ``FT.CREATE`` index per + collection. + + Supported search types: ``VECTOR``, ``FULL_TEXT`` and ``HYBRID``. + + Hybrid search semantics (IMPORTANT) + ----------------------------------- + Valkey Search hybrid queries follow a *filter-then-KNN* model: the text / + metadata filter pre-selects candidate keys and the KNN stage ranks them by + vector distance. This backend does **NOT** implement application-side + weighted score fusion. The ``vector_weight`` argument is therefore + accepted for interface compatibility but is **not honored** — passing + different weights does not change result ordering. A one-time warning is + emitted the first time a non-default weight is supplied. App-side score + fusion can be layered on later if weighted hybrid ranking is required. + """ + + @classmethod + def supported_search_types(cls) -> list[SearchType]: + return [SearchType.VECTOR, SearchType.FULL_TEXT, SearchType.HYBRID] + + def __init__(self, ap: app.Application): + if not VALKEY_SEARCH_AVAILABLE: + raise ImportError( + "valkey-glide is not installed. Install it with: pip install 'valkey-glide>=2.4.1,<3.0.0'" + ) + + self.ap = ap + config = self.ap.instance_config.data['vdb']['valkey_search'] + + self._host = config.get('host', 'localhost') + self._port = int(config.get('port', 6379)) + self._db = int(config.get('db', 0)) + # Auth / TLS are optional (toB / SaaS). Never logged. + self._password = config.get('password', '') or None + self._username = config.get('username', '') or None + self._tls = bool(config.get('tls', False)) + self._request_timeout = int(config.get('request_timeout', _DEFAULT_REQUEST_TIMEOUT_MS)) + + algorithm = str(config.get('index_algorithm', 'HNSW')).upper() + self._algorithm = VectorAlgorithm.FLAT if algorithm == 'FLAT' else VectorAlgorithm.HNSW + + metric = str(config.get('distance_metric', 'COSINE')).upper() + self._distance_metric = { + 'COSINE': DistanceMetricType.COSINE, + 'L2': DistanceMetricType.L2, + 'IP': DistanceMetricType.IP, + }.get(metric, DistanceMetricType.COSINE) + + # Lazily-created client (created on first use so a down Valkey does not + # block LangBot boot). + self._client: GlideClient | None = None + # Serializes lazy client creation so concurrent first-use callers do not + # each construct (and leak) a separate GlideClient. + self._client_lock = asyncio.Lock() + # Index names we have already ensured this process lifetime. + self._ensured_indexes: set[str] = set() + # Whether we have already warned about the non-honored vector_weight. + self._vector_weight_warned = False + + # ------------------------------------------------------------------ # + # Client lifecycle + # ------------------------------------------------------------------ # + async def _ensure_client(self) -> GlideClient: + """Create the glide client on first use (lazy, non-blocking boot).""" + if self._client is not None: + return self._client + # Double-checked locking: serialize creation so two concurrent + # first-use callers don't both build a client and leak one. + async with self._client_lock: + if self._client is not None: + return self._client + + credentials = None + if self._password is not None: + # username is optional alongside a password (ACL "user" vs default user). + credentials = ServerCredentials(password=self._password, username=self._username) + elif self._username is not None: + # A username without a password is not a valid credential pair, and silently + # connecting unauthenticated to a potentially shared Valkey instance is a + # security footgun (e.g. an env var that failed to resolve). Fail closed. + raise ValueError( + 'Valkey Search: a username was configured without a password. ' + 'Set both username and password to use ACL authentication, or remove both.' + ) + + conf = GlideClientConfiguration( + addresses=[NodeAddress(self._host, self._port)], + client_name=VALKEY_CLIENT_NAME, + database_id=self._db, + use_tls=self._tls, + lazy_connect=True, + credentials=credentials, + request_timeout=self._request_timeout, + ) + self._client = await GlideClient.create(conf) + self.ap.logger.info( + f'Initialized Valkey Search client to {self._host}:{self._port} (db={self._db}, tls={self._tls})' + ) + return self._client + + async def close(self) -> None: + """Close the glide client and reset state. + + Safe to call when no client was created. After ``close`` the next + operation transparently re-creates the client (``_ensure_client`` + guards on ``self._client is None``). + """ + if self._client is not None: + try: + await self._client.close() + except Exception: + self.ap.logger.warning('Valkey Search: error while closing client (ignored)') + finally: + self._client = None + self._ensured_indexes.clear() + + # ------------------------------------------------------------------ # + # Naming helpers + # ------------------------------------------------------------------ # + @staticmethod + def _index_name(collection: str) -> str: + return f'idx:{collection}' + + @staticmethod + def _key_prefix(collection: str) -> str: + return f'kb:{collection}:' + + @staticmethod + def _pack_vector(vec: list[float]) -> bytes: + """Pack a float vector into little-endian float32 bytes. + + Valkey Search stores and queries vectors as FLOAT32 little-endian + blobs (per the search query-language spec). + """ + return struct.pack(f'<{len(vec)}f', *[float(x) for x in vec]) + + @staticmethod + def _escape_tag(value: str) -> str: + """Escape characters that are special inside a TAG ``{...}`` clause. + + The backslash is escaped first so it cannot consume a following + escape. This neutralises injection-style values (quotes, parens, + ``|``, ``@``, ``:``, spaces, dashes) so a crafted ``file_id`` cannot + break out of the clause. + + Note: Valkey Search's TAG query parser cannot handle a literal brace + (``{`` / ``}``) or ``*`` even when backslash-escaped. Callers that pass + a ``file_id`` route it through ``_encode_and_escape_tag`` / + ``_encode_file_id`` first, which percent-encodes exactly those + characters, so an arbitrary ``file_id`` round-trips safely. This raw + escaper is still correct for all other special characters. + """ + out = [] + for ch in str(value): + if ch in '\\,.<>{}[]"\':;!@#$%^&*()-+=~| ': + out.append('\\') + out.append(ch) + return ''.join(out) + + @staticmethod + def _encode_file_id(value: str) -> str: + """Make a ``file_id`` safe to use as an FT TAG token AND query value. + + Percent-encodes the characters Valkey Search's TAG parser cannot handle + even when backslash-escaped (``{``, ``}``, ``*``) plus ``%`` itself for + reversibility. Applied identically at write time (the stored TAG field) + and query time (filters / ``delete_by_file_id``) so any value matches + itself. For normal UUID/hash ids none of these characters occur, so + this is a no-op. The original value is always kept verbatim in + ``metadata_json``; this encoded form is only ever used for the indexed + TAG. + """ + out = [] + for ch in str(value): + if ch in _FT_UNSAFE_TAG_CHARS: + out.append('%{:02X}'.format(ord(ch))) + else: + out.append(ch) + return ''.join(out) + + def _encode_and_escape_tag(self, value: str) -> str: + """Encode an FT-unsafe ``file_id`` then escape TAG special chars.""" + return self._escape_tag(self._encode_file_id(value)) + + # ------------------------------------------------------------------ # + # Filter mapping (canonical triples -> FT query fragment) + # ------------------------------------------------------------------ # + def _triples_to_ft(self, filter: dict[str, Any] | None) -> str: + """Translate a canonical filter dict into an FT filter expression. + + Only indexed fields (``file_id``) are filterable; unsupported fields + are dropped with a warning (matching the Milvus / pgvector pattern). + Returns an empty string when there is no usable filter. + """ + triples = normalize_filter(filter) + if not triples: + return '' + triples = strip_unsupported_fields(triples, _SUPPORTED_FILTER_FIELDS) + + fragments: list[str] = [] + for field, op, value in triples: + # All currently-indexed fields are TAG fields; file_id values are + # encoded (FT-unsafe chars) then escaped so any value round-trips. + if op == '$eq': + fragments.append(f'@{field}:{{{self._encode_and_escape_tag(value)}}}') + elif op == '$ne': + fragments.append(f'-@{field}:{{{self._encode_and_escape_tag(value)}}}') + elif op == '$in': + joined = '|'.join(self._encode_and_escape_tag(v) for v in value) + fragments.append(f'@{field}:{{{joined}}}') + elif op == '$nin': + joined = '|'.join(self._encode_and_escape_tag(v) for v in value) + fragments.append(f'-@{field}:{{{joined}}}') + elif op == '$gt': + fragments.append(f'@{field}:[({float(value)} +inf]') + elif op == '$gte': + fragments.append(f'@{field}:[{float(value)} +inf]') + elif op == '$lt': + fragments.append(f'@{field}:[-inf ({float(value)}]') + elif op == '$lte': + fragments.append(f'@{field}:[-inf {float(value)}]') + else: + # normalize_filter() already rejects unknown operators, so this + # only triggers if SUPPORTED_OPS grows without this chain being + # updated. Fail closed (rather than silently dropping the + # condition, which would widen delete_by_filter's match set). + raise ValueError(f'Valkey Search: unhandled filter operator {op!r} on field {field!r}') + + return ' '.join(fragments) + + @staticmethod + def _build_text_clause(text: str) -> str: + """Build a field-scoped full-text clause for the ``document`` field. + + Each whitespace-delimited word becomes a ``@document:`` term and + the terms are AND-ed (space separated). FT special characters in each + term are escaped. Returns an empty string when *text* has no words. + """ + words = [w for w in str(text).split() if w] + if not words: + return '' + terms = [f'@{_FIELD_DOCUMENT}:{ValkeySearchVectorDatabase._escape_text(w)}' for w in words] + return ' '.join(terms) + + @staticmethod + def _escape_text(text: str) -> str: + """Escape FT full-text special characters in a single term.""" + out = [] + for ch in str(text): + if ch in '@!{}[]()|-"~*:\\': + out.append('\\') + out.append(ch) + return ''.join(out) + + # ------------------------------------------------------------------ # + # Index management + # ------------------------------------------------------------------ # + async def _ensure_index(self, client: GlideClient, collection: str, dim: int) -> None: + index = self._index_name(collection) + if index in self._ensured_indexes: + return + + # ft.info is O(1) and raises RequestError when the index is absent — + # cheaper than ft.list (O(n) over all indexes) and it closes the + # check-then-create TOCTOU window. + try: + await ft.info(client, index) + self._ensured_indexes.add(index) + return + except RequestError: + pass + + if self._algorithm == VectorAlgorithm.FLAT: + vector_attrs = VectorFieldAttributesFlat( + dimensions=dim, + distance_metric=self._distance_metric, + type=VectorType.FLOAT32, + ) + else: + vector_attrs = VectorFieldAttributesHnsw( + dimensions=dim, + distance_metric=self._distance_metric, + type=VectorType.FLOAT32, + ) + + schema = [ + VectorField(name=_FIELD_VECTOR, algorithm=self._algorithm, attributes=vector_attrs), + TagField(name=_FIELD_FILE_ID), + TextField(name=_FIELD_DOCUMENT), + ] + options = FtCreateOptions(data_type=DataType.HASH, prefixes=[self._key_prefix(collection)]) + await ft.create(client, index, schema, options) + self._ensured_indexes.add(index) + self.ap.logger.info( + f"Valkey Search index '{index}' created (dim={dim}, algo={self._algorithm.value}, " + f'metric={self._distance_metric.value})' + ) + + @staticmethod + def _decode(value: Any) -> str: + if isinstance(value, (bytes, bytearray, memoryview)): + return bytes(value).decode('utf-8', errors='replace') + return str(value) + + # ------------------------------------------------------------------ # + # VectorDatabase ABC implementation + # ------------------------------------------------------------------ # + async def get_or_create_collection(self, collection: str): + """Ensure a client exists. + + The index itself requires the vector dimension, which is only known at + first ``add_embeddings`` (same constraint as Qdrant / SeekDB), so this + is a best-effort no-op when the index does not yet exist. + """ + await self._ensure_client() + + async def add_embeddings( + self, + collection: str, + ids: list[str], + embeddings_list: list[list[float]], + metadatas: list[dict[str, Any]], + documents: list[str] | None = None, + ) -> None: + if not embeddings_list: + return + + client = await self._ensure_client() + dim = len(embeddings_list[0]) + # The index schema is fixed to the first embedding's dimension. A later + # embedding of a different length would be packed into a wrong-sized + # blob that Valkey stores silently but that yields garbage KNN + # distances, so reject mixed dimensions up-front. + if any(len(e) != dim for e in embeddings_list[1:]): + raise ValueError(f'All embeddings must have dimension {dim}; got mixed lengths') + await self._ensure_index(client, collection, dim) + + prefix = self._key_prefix(collection) + + batch = Batch(is_atomic=False) + for i, _id in enumerate(ids): + key = prefix + str(_id) + metadata = metadatas[i] if i < len(metadatas) else {} + mapping: dict[str, Any] = { + _FIELD_VECTOR: self._pack_vector(embeddings_list[i]), + _FIELD_METADATA: json.dumps(metadata, ensure_ascii=False), + } + file_id = metadata.get('file_id') + if file_id is not None: + mapping[_FIELD_FILE_ID] = self._encode_file_id(str(file_id)) + if documents is not None and i < len(documents) and documents[i] is not None: + mapping[_FIELD_DOCUMENT] = documents[i] + + batch.hset(key, mapping) + + # Pipeline all HSETs into a single round-trip (non-atomic) instead of + # one await per embedding, which is N sequential round-trips for N + # chunks. + await client.exec(batch, raise_on_error=True) + + self.ap.logger.info(f"Added {len(ids)} embeddings to Valkey Search collection '{collection}'") + + async def search( + self, + collection: str, + query_embedding: list[float], + k: int = 5, + search_type: str = 'vector', + query_text: str = '', + filter: dict[str, Any] | None = None, + vector_weight: float | None = None, + ) -> dict[str, Any]: + client = await self._ensure_client() + index = self._index_name(collection) + + if not await self._index_exists(client, index): + return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]} + + # vector_weight is accepted for interface parity but NOT honored by this + # backend (filter-then-KNN, no weighted fusion). Warn once. + if vector_weight is not None and not self._vector_weight_warned: + self.ap.logger.warning( + 'Valkey Search backend does not honor vector_weight: hybrid search uses ' + 'filter-then-KNN without weighted score fusion. The vector_weight value ' + 'is ignored. See docs/VALKEY_SEARCH_INTEGRATION.md.' + ) + self._vector_weight_warned = True + + filter_expr = self._triples_to_ft(filter) + + if search_type == SearchType.FULL_TEXT: + if not query_text: + return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]} + text_clause = self._build_text_clause(query_text) + if not text_clause: + return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]} + query = f'{filter_expr} {text_clause}'.strip() if filter_expr else text_clause + return await self._run_text_search(client, index, query, k) + + if search_type == SearchType.HYBRID: + # Filter / text pre-selects candidates; KNN ranks. No fusion. + pre = filter_expr + if query_text: + text_clause = self._build_text_clause(query_text) + if text_clause: + pre = f'{pre} {text_clause}'.strip() if pre else text_clause + pre = pre or '*' + query = f'{self._wrap_pre(pre)}=>[KNN {k} @{_FIELD_VECTOR} $BLOB AS {_VEC_SCORE_ALIAS}]' + return await self._run_knn_search(client, index, query, query_embedding, k) + + # Default: pure VECTOR search. + pre = filter_expr or '*' + query = f'{self._wrap_pre(pre)}=>[KNN {k} @{_FIELD_VECTOR} $BLOB AS {_VEC_SCORE_ALIAS}]' + return await self._run_knn_search(client, index, query, query_embedding, k) + + @staticmethod + def _wrap_pre(pre: str) -> str: + """Parenthesize a multi-condition pre-filter before the ``=>`` KNN clause. + + When ``pre`` combines several terms (e.g. ``@file_id:{x} @document:term``) + the Valkey Search parser can otherwise mis-associate only the last term + with the KNN clause. Wrapping the whole expression forces correct + grouping. A bare ``*`` (match-all) and single-term expressions are left + untouched. + """ + if pre and pre != '*' and ' ' in pre.strip(): + return f'({pre})' + return pre + + async def _run_knn_search( + self, + client: GlideClient, + index: str, + query: str, + query_embedding: list[float], + k: int, + ) -> dict[str, Any]: + options = FtSearchOptions( + params={'BLOB': self._pack_vector(list(query_embedding))}, + return_fields=[ + ReturnField(field_identifier=_VEC_SCORE_ALIAS, alias='distance'), + ReturnField(field_identifier=_FIELD_DOCUMENT), + ReturnField(field_identifier=_FIELD_METADATA), + ], + limit=FtSearchLimit(0, k), + dialect=2, + ) + try: + reply = await ft.search(client, index, query, options) + except Exception as exc: + if self._is_missing_index_error(exc): + return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]} + raise + return self._reply_to_chroma(index, reply, has_distance=True) + + async def _run_text_search( + self, + client: GlideClient, + index: str, + query: str, + k: int, + ) -> dict[str, Any]: + options = FtSearchOptions( + return_fields=[ + ReturnField(field_identifier=_FIELD_DOCUMENT), + ReturnField(field_identifier=_FIELD_METADATA), + ], + limit=FtSearchLimit(0, k), + dialect=2, + ) + try: + reply = await ft.search(client, index, query, options) + except Exception as exc: + if self._is_missing_index_error(exc): + return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]} + raise + return self._reply_to_chroma(index, reply, has_distance=False) + + @staticmethod + def _is_missing_index_error(exc: Exception) -> bool: + """Return True if *exc* indicates the FT index does not exist. + + ``FT.DROPINDEX`` is applied eventually, so an index can briefly still + appear in ``FT._LIST`` after being dropped; a follow-up search then + fails with a "not found" error which we treat as an empty result. + """ + message = str(exc).lower() + return 'not found' in message and 'index' in message + + def _iter_reply_docs(self, reply: Any, prefix: str): + """Yield ``(doc_id, decoded_fields)`` pairs from an FT.SEARCH reply. + + glide returns ``[total, {key: {field: value}, ...}]``. This shared + iterator decodes each key, strips the per-collection prefix to recover + the original document id, and decodes the field map — the logic both + ``_reply_to_chroma`` and ``list_by_filter`` need. + """ + docs = reply[1] if reply and len(reply) >= 2 and isinstance(reply[1], dict) else {} + for key, fields in docs.items(): + key_str = self._decode(key) + doc_id = key_str[len(prefix) :] if prefix and key_str.startswith(prefix) else key_str + decoded_fields = {self._decode(fk): fv for fk, fv in fields.items()} if isinstance(fields, dict) else {} + yield doc_id, decoded_fields + + def _reply_to_chroma(self, index: str, reply: Any, has_distance: bool) -> dict[str, Any]: + """Convert an FT.SEARCH reply into Chroma-style nested lists. + + The KNN score field (aliased ``distance``) is a COSINE/L2 distance + directly, so no inversion is needed (unlike Qdrant). + """ + ids: list[str] = [] + distances: list[float] = [] + metadatas: list[dict[str, Any]] = [] + + if not reply or len(reply) < 2: + return {'ids': [ids], 'metadatas': [metadatas], 'distances': [distances]} + + prefix = self._key_prefix(index[len('idx:') :]) if index.startswith('idx:') else '' + + for doc_id, decoded_fields in self._iter_reply_docs(reply, prefix): + ids.append(doc_id) + + if has_distance and 'distance' in decoded_fields: + try: + distances.append(float(self._decode(decoded_fields['distance']))) + except (TypeError, ValueError): + distances.append(0.0) + else: + distances.append(0.0) + + metadata: dict[str, Any] = {} + raw_meta = decoded_fields.get(_FIELD_METADATA) + if raw_meta is not None: + try: + metadata = json.loads(self._decode(raw_meta)) + except (TypeError, ValueError): + metadata = {} + metadatas.append(metadata) + + return {'ids': [ids], 'metadatas': [metadatas], 'distances': [distances]} + + async def delete_by_file_id(self, collection: str, file_id: str) -> None: + client = await self._ensure_client() + index = self._index_name(collection) + if not await self._index_exists(client, index): + self.ap.logger.warning(f"Valkey Search collection '{collection}' not found for deletion") + return + + query = f'@{_FIELD_FILE_ID}:{{{self._encode_and_escape_tag(file_id)}}}' + keys = await self._search_keys(client, index, query) + if keys: + await client.delete(keys) + self.ap.logger.info( + f"Deleted {len(keys)} embeddings from Valkey Search collection '{collection}' with file_id: {file_id}" + ) + + async def delete_by_filter(self, collection: str, filter: dict[str, Any]) -> int: + client = await self._ensure_client() + index = self._index_name(collection) + if not await self._index_exists(client, index): + self.ap.logger.warning(f"Valkey Search collection '{collection}' not found for deletion") + return 0 + + # Guard against accidental mass deletion: a non-empty filter that maps + # to no usable (indexed) conditions must NOT fall back to match-all and + # wipe the whole collection. Skip instead (matching Milvus / pgvector). + query = self._triples_to_ft(filter) + if not query: + self.ap.logger.warning( + "Valkey Search delete_by_filter on '%s': filter produced no usable conditions, skipping", + collection, + ) + return 0 + keys = await self._search_keys(client, index, query) + if keys: + await client.delete(keys) + self.ap.logger.info(f"Deleted {len(keys)} embeddings from Valkey Search collection '{collection}' by filter") + return len(keys) + + async def list_by_filter( + self, + collection: str, + filter: dict[str, Any] | None = None, + limit: int = 20, + offset: int = 0, + ) -> tuple[list[dict[str, Any]], int]: + client = await self._ensure_client() + index = self._index_name(collection) + if not await self._index_exists(client, index): + return [], 0 + + query = self._triples_to_ft(filter) or _MATCH_ALL + options = FtSearchOptions( + return_fields=[ + ReturnField(field_identifier=_FIELD_DOCUMENT), + ReturnField(field_identifier=_FIELD_METADATA), + ], + limit=FtSearchLimit(offset, limit), + dialect=2, + ) + try: + reply = await ft.search(client, index, query, options) + except Exception as exc: + if self._is_missing_index_error(exc): + return [], 0 + raise + + total = 0 + if reply: + try: + total = int(reply[0]) + except (TypeError, ValueError): + total = 0 + + prefix = self._key_prefix(collection) + items: list[dict[str, Any]] = [] + for doc_id, decoded_fields in self._iter_reply_docs(reply, prefix): + document = decoded_fields.get(_FIELD_DOCUMENT) + metadata: dict[str, Any] = {} + raw_meta = decoded_fields.get(_FIELD_METADATA) + if raw_meta is not None: + try: + metadata = json.loads(self._decode(raw_meta)) + except (TypeError, ValueError): + metadata = {} + + items.append( + { + 'id': doc_id, + 'document': self._decode(document) if document is not None else None, + 'metadata': metadata, + } + ) + + return items, total + + async def delete_collection(self, collection: str): + client = await self._ensure_client() + index = self._index_name(collection) + self._ensured_indexes.discard(index) + + if await self._index_exists(client, index): + try: + await ft.dropindex(client, index) + except RequestError: + # The index was already dropped (e.g. by a concurrent process) + # between the existence check and this call — benign. Other + # errors (connection / auth) must propagate so the caller knows + # the operation failed rather than silently SCAN-deleting next. + pass + + # DROPINDEX does not remove the underlying hashes; delete them too. + prefix = self._key_prefix(collection) + cursor = b'0' + deleted = 0 + for _ in range(_MAX_SCAN_ROUNDS): + cursor, keys = await client.scan(cursor, match=f'{prefix}*', count=500) + if keys: + await client.delete(keys) + deleted += len(keys) + if cursor in (b'0', '0', 0): + break + self.ap.logger.info(f"Valkey Search collection '{collection}' deleted ({deleted} keys removed)") + + # ------------------------------------------------------------------ # + # Internal search helpers + # ------------------------------------------------------------------ # + async def _index_exists(self, client: GlideClient, index: str) -> bool: + if index in self._ensured_indexes: + return True + # ft.info is O(1) and raises RequestError when the index does not + # exist, vs ft.list which is O(n) over every index on the server and + # was being paid on the first query to each collection. + try: + await ft.info(client, index) + self._ensured_indexes.add(index) + return True + except RequestError: + return False + + async def _search_keys(self, client: GlideClient, index: str, query: str) -> list[str]: + """Return all matching document keys for a query (NOCONTENT). + + Paginates through the full result set in pages of ``_DELETE_SCAN_BATCH`` + so that queries matching more than one page of chunks are fully + enumerated (avoids silently truncating deletes and leaving orphaned + vectors). + """ + keys: list[str] = [] + offset = 0 + while True: + options = FtSearchOptions( + nocontent=True, + limit=FtSearchLimit(offset, _DELETE_SCAN_BATCH), + dialect=2, + ) + try: + reply = await ft.search(client, index, query, options) + except Exception as exc: + if self._is_missing_index_error(exc): + return keys + raise + + if not reply or len(reply) < 2: + break + + # reply[0] is the total match count; reply[1] holds this page. + total = 0 + try: + total = int(reply[0]) + except (TypeError, ValueError): + total = 0 + + docs = reply[1] + if isinstance(docs, dict): + page = [self._decode(k) for k in docs.keys()] + elif isinstance(docs, (list, tuple)): + page = [self._decode(k) for k in docs] + else: + page = [] + + if not page: + break + keys.extend(page) + + offset += len(page) + if offset >= total or len(page) < _DELETE_SCAN_BATCH: + break + + return keys diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 5cf9b98b8..f4ae79bf8 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -1,8 +1,13 @@ -admins: [] api: port: 5300 webhook_prefix: 'http://127.0.0.1:5300' extra_webhook_prefix: '' + # Global API key for the HTTP service API and the MCP server. When set to a + # non-empty string, this key is accepted anywhere a web-UI-created API key is + # accepted (X-API-Key header or "Authorization: Bearer "), WITHOUT any + # login session and without a database record. Leave empty to disable. + # Keep this value secret; only enable it on trusted/internal deployments. + global_api_key: '' command: enable: true prefix: @@ -21,10 +26,23 @@ system: recovery_key: '' allow_modify_login_info: true disabled_adapters: [] + # Public outbound IP addresses of this LangBot deployment. Some platforms + # (e.g. WeCom, WeChat Official Account, QQ Official API) require the + # caller's IPs to be added to their trusted-IP / IP-whitelist settings. + # When set, the web UI shows these IPs on the bot config form of such + # adapters. Also settable via the SYSTEM__OUTBOUND_IPS env var + # (comma-separated). Empty list = hidden in the web UI. + outbound_ips: [] limitation: max_bots: -1 max_pipelines: -1 max_extensions: -1 + # When set to a non-empty string, every pipeline is forced to use this + # Box sandbox-scope template regardless of its own configuration, and + # the per-pipeline "Sandbox Scope" selector is locked in the web UI. + # Used by SaaS deployments to confine a tenant to a single shared + # sandbox (set to '{global}'). Empty string = no restriction. + force_box_session_id_template: '' task_retention: # Keep at most this many completed async task records in memory completed_limit: 200 @@ -69,6 +87,16 @@ vdb: database: 'langbot' user: 'postgres' password: 'postgres' + valkey_search: + host: 'localhost' + port: 6379 # integration tests use 6380 -> valkey/valkey-bundle:9.1.0 + db: 0 + password: '' # optional (toB auth) + username: '' # optional (ACL user, toB) + tls: false # optional (toB/SaaS) + index_algorithm: 'HNSW' # HNSW | FLAT + distance_metric: 'COSINE' # COSINE | L2 | IP + request_timeout: 5000 # per-request timeout in ms (glide default 250ms is too low for KNN) storage: use: local cleanup: @@ -104,6 +132,42 @@ monitoring: check_interval_hours: 1 # Number of expired rows to delete per table batch delete_batch_size: 1000 +box: + # Master switch for the Box sandbox runtime. When false, LangBot does NOT + # attempt to connect to a remote Box runtime nor start a local stdio Box + # subprocess. Disabling Box also disables every feature that depends on it: + # the native sandbox tools (exec/read/write/edit/glob/grep), the activate + # skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still + # be listed read-only and http/sse MCP servers continue to work. + enabled: true + backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND. + runtime: + endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime. + local: + profile: 'default' + image: '' # Custom local sandbox image. Leave empty to use the profile default. + host_root: './data/box' # Base host directory for local workspace mounts. Docker deployments should override this with an absolute host path. + default_workspace: '' # Defaults to '/default'. Relative paths are resolved under host_root. + skills_root: 'skills' # Box-owned skill package directory. Relative paths are resolved under host_root. + allowed_mount_roots: # Defaults to [''] when left empty. + - './data/box' + - '/tmp' + workspace_quota_mb: null # Optional disk quota override (>= 0). null = profile default. + # Default nsjail cgroup memory limit for each MCP stdio server process, in MB. + # Node.js MCP servers (npx/bunx) need more memory than Python ones because V8 + # and WebAssembly modules (e.g. undici llhttp) reserve large virtual address + # space at startup. Setting this too low causes processes to be killed with + # return_code=137 (OOM kill); the symptom is "Box managed process exited + # unexpectedly" in the logs. Raise on machines with ample RAM; lower only if + # you run exclusively Python (uvx) MCP servers. + # Can also be set via BOX__DEFAULT_MEMORY_MB. Default: 1536. + default_memory_mb: 1536 + docker: + cpu_limit_enabled: true # When false, Docker sandbox containers are started without --cpus. Memory and PID limits still apply. + e2b: + api_key: '' # Can also be set via E2B_API_KEY env var. + api_url: '' # Custom API URL for self-hosted deployments. + template: '' # Default template ID (e.g. 'base', 'python-3.11'). space: # Space service URL for OAuth and API url: 'https://space.langbot.app' diff --git a/src/langbot/templates/default-pipeline-config.json b/src/langbot/templates/default-pipeline-config.json index fe6e28427..78e2ec958 100644 --- a/src/langbot/templates/default-pipeline-config.json +++ b/src/langbot/templates/default-pipeline-config.json @@ -50,10 +50,11 @@ "prompt": [ { "role": "system", - "content": "You are a helpful assistant." + "content": "You are a helpful assistant. When tools are available, use them for exact calculations, data processing, and code execution instead of guessing. Unless the user explicitly asks for code or a script, return the result directly instead of printing the generated code." } ], "knowledge-bases": [], + "box-session-id-template": "{launcher_type}_{launcher_id}", "rerank-model": "", "rerank-top-k": 5 }, diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml index fd68fb475..b5c5eb79f 100644 --- a/src/langbot/templates/metadata/pipeline/ai.yaml +++ b/src/langbot/templates/metadata/pipeline/ai.yaml @@ -47,6 +47,14 @@ stages: label: en_US: Langflow API zh_Hans: Langflow API + - name: weknora-api + label: + en_US: WeKnora API + zh_Hans: WeKnora API + - name: deerflow-api + label: + en_US: DeerFlow API + zh_Hans: DeerFlow API - name: expire-time label: en_US: Conversation expire time (seconds) @@ -110,16 +118,96 @@ stages: default: - role: system content: "You are a helpful assistant." - - name: knowledge-bases + - name: box-session-id-template label: - en_US: Knowledge Bases - zh_Hans: 知识库 + en_US: Sandbox Scope + zh_Hans: 沙箱作用域 + zh_Hant: 沙箱作用域 + ja_JP: サンドボックススコープ + vi_VN: Phạm vi Sandbox + th_TH: ขอบเขต Sandbox + es_ES: Alcance del Sandbox + ru_RU: Область песочницы description: - en_US: Configure the knowledge bases to use for the agent, if not selected, the agent will directly use the LLM to reply - zh_Hans: 配置用于提升回复质量的知识库,若不选择,则直接使用大模型回复 - type: knowledge-base-multi-selector + en_US: Determines how sandbox environments are shared across messages. + zh_Hans: 决定沙箱环境在不同消息间的共享方式。 + zh_Hant: 決定沙箱環境在不同訊息間的共享方式。 + ja_JP: メッセージ間でサンドボックス環境を共有する方法を決定します。 + vi_VN: Xác định cách chia sẻ môi trường sandbox giữa các tin nhắn. + th_TH: กำหนดวิธีแชร์สภาพแวดล้อม Sandbox ระหว่างข้อความ + es_ES: Determina cómo se comparten los entornos sandbox entre mensajes. + ru_RU: Определяет, как песочницы используются совместно между сообщениями. + disable_if: + field: __system.box_scope_editable + operator: eq + value: false + disabled_tooltip: + en_US: >- + Sandbox scope can't be changed: either the Box sandbox is disabled + or unavailable (enable it in config.yaml with box.enabled = true and + ensure the runtime is reachable), or this deployment pins all + pipelines to a fixed scope. + zh_Hans: "无法修改沙箱作用域:Box 沙箱已禁用或不可用(请在配置中启用 box.enabled = true 并确认运行时连接正常),或本部署已将所有流水线固定为统一作用域。" + zh_Hant: "無法修改沙箱作用域:Box 沙箱已停用或無法使用(請在設定中啟用 box.enabled = true 並確認執行時連線正常),或本部署已將所有流水線固定為統一作用域。" + ja_JP: "サンドボックススコープを変更できません:Box サンドボックスが無効/利用不可(設定で box.enabled = true にしてランタイム接続を確認)、またはこのデプロイがすべてのパイプラインを固定スコープに制限しています。" + vi_VN: "Không thể thay đổi phạm vi sandbox:Box sandbox bị tắt hoặc không khả dụng (bật box.enabled = true và đảm bảo runtime hoạt động), hoặc bản triển khai này cố định mọi pipeline về một phạm vi." + th_TH: "ไม่สามารถเปลี่ยนขอบเขต Sandbox:Box sandbox ถูกปิดหรือไม่พร้อมใช้งาน (เปิด box.enabled = true และตรวจสอบรันไทม์) หรือการ deploy นี้ล็อกทุก pipeline ไว้ที่ขอบเขตเดียว" + es_ES: "No se puede cambiar el alcance del sandbox: el sandbox de Box está desactivado o no disponible (actívelo con box.enabled = true y verifique el runtime), o este despliegue fija todas las pipelines a un alcance único." + ru_RU: "Невозможно изменить область песочницы: песочница Box отключена или недоступна (включите box.enabled = true и проверьте среду выполнения), либо это развёртывание фиксирует единую область для всех конвейеров." + type: select required: false - default: [] + default: "{launcher_type}_{launcher_id}" + options: + - name: "{global}" + label: + en_US: Global (shared by all) + zh_Hans: 全局(所有人共享) + zh_Hant: 全域(所有人共用) + ja_JP: グローバル(全員共有) + vi_VN: Toàn cục (chia sẻ cho tất cả) + th_TH: ทั่วไป (แชร์ทั้งหมด) + es_ES: Global (compartido por todos) + ru_RU: Глобальный (общий для всех) + - name: "{launcher_type}_{launcher_id}" + label: + en_US: Per chat (Recommended) + zh_Hans: 每个会话(推荐) + zh_Hant: 每個會話(推薦) + ja_JP: チャットごと(推奨) + vi_VN: Mỗi cuộc trò chuyện (Khuyến nghị) + th_TH: ต่อแชท (แนะนำ) + es_ES: Por chat (Recomendado) + ru_RU: По чату (Рекомендуется) + - name: "{launcher_type}_{launcher_id}_{sender_id}" + label: + en_US: Per user in chat + zh_Hans: 会话中每个用户 + zh_Hant: 會話中每個用戶 + ja_JP: チャット内のユーザーごと + vi_VN: Mỗi người dùng trong cuộc trò chuyện + th_TH: ต่อผู้ใช้ในแชท + es_ES: Por usuario en chat + ru_RU: По пользователю в чате + - name: "{launcher_type}_{launcher_id}_{conversation_id}" + label: + en_US: Per conversation context + zh_Hans: 每个对话上下文 + zh_Hant: 每個對話上下文 + ja_JP: 会話コンテキストごと + vi_VN: Mỗi ngữ cảnh hội thoại + th_TH: ต่อบริบทการสนทนา + es_ES: Por contexto de conversación + ru_RU: По контексту разговора + - name: "{query_id}" + label: + en_US: Per message (isolated) + zh_Hans: 每条消息(完全隔离) + zh_Hant: 每條訊息(完全隔離) + ja_JP: メッセージごと(隔離) + vi_VN: Mỗi tin nhắn (cách ly) + th_TH: ต่อข้อความ (แยกส่วน) + es_ES: Por mensaje (aislado) + ru_RU: По сообщению (изолированно) show_if: field: __system.is_wizard operator: neq @@ -152,6 +240,34 @@ stages: field: rerank-model operator: neq value: '' + - name: tools + label: + en_US: Tools + zh_Hans: 工具 + description: + en_US: Select plugin, MCP, skill, and built-in tools available to this Local Agent. + zh_Hans: 选择此内置 Agent 可以调用的插件、MCP、技能和内置工具。 + type: rich-tools-selector + required: false + default: [] + show_if: + field: __system.is_wizard + operator: neq + value: true + - name: knowledge-bases + label: + en_US: Resources + zh_Hans: 资源 + description: + en_US: Select MCP resources and knowledge bases available to this Local Agent. + zh_Hans: 选择此内置 Agent 可以读取的 MCP 资源和知识库。 + type: resources-selector + required: false + default: [] + show_if: + field: __system.is_wizard + operator: neq + value: true - name: dify-service-api label: en_US: Dify Service API @@ -560,3 +676,215 @@ stages: type: json required: false default: '{}' + - name: weknora-api + label: + en_US: WeKnora API + zh_Hans: WeKnora API + description: + en_US: Configure the WeKnora API of the pipeline + zh_Hans: 配置 WeKnora API + config: + - name: base-url + label: + en_US: Base URL + zh_Hans: 基础 URL + description: + en_US: The base URL of the WeKnora server (with /api/v1) + zh_Hans: WeKnora 服务器的基础 URL(包含 /api/v1) + type: string + required: true + default: 'http://localhost:8080/api/v1' + - name: api-key + label: + en_US: API Key + zh_Hans: API 密钥 + description: + en_US: The API key for WeKnora, generated from WeKnora frontend Settings → API Keys + zh_Hans: WeKnora 的 API 密钥,从 WeKnora 前端 设置 → API Keys 生成 + type: string + required: true + default: '' + - name: app-type + label: + en_US: App Type + zh_Hans: 应用类型 + type: select + required: true + default: agent + options: + - name: agent + label: + en_US: Agent (Smart Reasoning) + zh_Hans: Agent(智能推理) + - name: chat + label: + en_US: Chat (Knowledge Base RAG) + zh_Hans: 聊天(知识库 RAG) + - name: agent-id + label: + en_US: Agent ID + zh_Hans: 智能体 ID + description: + en_US: The Agent ID to use. Built-in agents include builtin-quick-answer, builtin-smart-reasoning, builtin-data-analyst + zh_Hans: 要使用的智能体 ID。内置智能体:builtin-quick-answer、builtin-smart-reasoning、builtin-data-analyst + type: string + required: true + default: 'builtin-smart-reasoning' + - name: knowledge-base-ids + label: + en_US: Knowledge Base IDs + zh_Hans: 知识库 ID 列表 + description: + en_US: List of WeKnora knowledge base IDs to use (one per line) + zh_Hans: 要使用的 WeKnora 知识库 ID 列表(每行一个) + type: array + required: false + default: [] + - name: web-search-enabled + label: + en_US: Enable Web Search + zh_Hans: 启用网络搜索 + description: + en_US: Whether to enable web search in agent mode + zh_Hans: 在 Agent 模式下是否启用网络搜索 + type: boolean + required: false + default: false + - name: timeout + label: + en_US: Timeout + zh_Hans: 超时时间 + description: + en_US: Request timeout in seconds + zh_Hans: 请求超时时间(秒) + type: integer + required: false + default: 120 + - name: base-prompt + label: + en_US: Base Prompt + zh_Hans: 基础提示词 + description: + en_US: Default prompt when user message is empty (e.g. only images) + zh_Hans: 当用户消息为空(例如仅图片)时使用的默认提示词 + type: string + required: false + default: '请回答用户的问题。' + - name: deerflow-api + label: + en_US: DeerFlow API + zh_Hans: DeerFlow API + description: + en_US: Configure the DeerFlow LangGraph API of the pipeline + zh_Hans: 配置 DeerFlow LangGraph API + config: + - name: api-base + label: + en_US: API Base URL + zh_Hans: API 基础 URL + description: + en_US: The base URL of the DeerFlow server (e.g. http://127.0.0.1:2026) + zh_Hans: DeerFlow 服务器的基础 URL(例如 http://127.0.0.1:2026) + type: string + required: true + default: 'http://127.0.0.1:2026' + - name: api-key + label: + en_US: API Key + zh_Hans: API 密钥 + description: + en_US: Optional API key for DeerFlow (leave empty if not required) + zh_Hans: DeerFlow 的 API 密钥(如果不需要可留空) + type: string + required: false + default: '' + - name: auth-header + label: + en_US: Auth Header Name + zh_Hans: 鉴权请求头名称 + description: + en_US: Custom auth header name. Leave empty to use "x-api-key" + zh_Hans: 自定义鉴权请求头名称,留空则使用 "x-api-key" + type: string + required: false + default: '' + - name: assistant-id + label: + en_US: Assistant ID + zh_Hans: 助手 ID + description: + en_US: The DeerFlow assistant/graph id (default lead_agent) + zh_Hans: DeerFlow 助手/图 ID(默认 lead_agent) + type: string + required: true + default: 'lead_agent' + - name: model-name + label: + en_US: Model Name + zh_Hans: 模型名称 + description: + en_US: Optional model override forwarded to DeerFlow configurable + zh_Hans: 可选的模型名称覆盖,会作为 configurable 转发给 DeerFlow + type: string + required: false + default: '' + - name: thinking-enabled + label: + en_US: Enable Thinking + zh_Hans: 启用思考 + description: + en_US: Whether to enable DeerFlow thinking mode + zh_Hans: 是否启用 DeerFlow 思考模式 + type: boolean + required: false + default: false + - name: plan-mode + label: + en_US: Plan Mode + zh_Hans: 规划模式 + description: + en_US: Whether to enable DeerFlow plan mode + zh_Hans: 是否启用 DeerFlow 规划模式 + type: boolean + required: false + default: false + - name: subagent-enabled + label: + en_US: Enable Subagents + zh_Hans: 启用子代理 + description: + en_US: Whether to enable parallel subagent execution + zh_Hans: 是否启用并行子代理执行 + type: boolean + required: false + default: false + - name: max-concurrent-subagents + label: + en_US: Max Concurrent Subagents + zh_Hans: 最大并发子代理数 + description: + en_US: Maximum number of concurrent subagents (only effective when subagents are enabled) + zh_Hans: 最大并发子代理数(仅在启用子代理时生效) + type: integer + required: false + default: 3 + - name: timeout + label: + en_US: Timeout + zh_Hans: 超时时间 + description: + en_US: Request timeout in seconds (DeerFlow runs may take a long time) + zh_Hans: 请求超时时间(秒),DeerFlow 运行可能耗时较长 + type: integer + required: false + default: 300 + - name: recursion-limit + label: + en_US: Recursion Limit + zh_Hans: 递归上限 + description: + en_US: LangGraph recursion limit for a single run + zh_Hans: 单次运行的 LangGraph 递归上限 + type: integer + required: false + default: 1000 diff --git a/tests/README.md b/tests/README.md index e490ed5cf..3a110b9dc 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,7 @@ # LangBot Test Suite -This directory contains the test suite for LangBot, with a focus on comprehensive unit testing of pipeline stages. +This directory contains the LangBot backend test suite, including unit tests, +integration tests, startup E2E tests, and container-backed Box runtime tests. ## Quality Gate Layers @@ -10,10 +11,15 @@ LangBot uses a layered quality gate system for developers and CI: |-------|---------|--------------|-------------| | **Quick** | `make test-quick` or `bash scripts/test-quick.sh` | Ruff lint + Unit tests + Smoke tests | Before every commit | | **Fast Integration** | `make test-integration-fast` or `bash scripts/test-integration-fast.sh` | SQLite/API/Pipeline integration (no external services) | Before PR, weekly | +| **Backend E2E** | `uv run --python 3.12 pytest tests/e2e -q --tb=short` | Starts a real LangBot process with minimal config | Before release, CI | +| **Box Integration** | `uv run --python 3.12 pytest tests/integration_tests -q --tb=short` | Real Box sandbox/runtime integration | Before Box/runtime changes, CI | +| **Frontend E2E** | `cd web && pnpm test:e2e` | Playwright smoke tests with mocked backend and Space APIs | Before web changes, CI | | **Coverage Gate** | `make test-coverage` or `bash scripts/test-coverage.sh` | All tests with coverage, threshold: 18% | Before merge, CI | | **Full Local** | `make test-all-local` | Quick + Integration + Coverage | Before major changes | -**Note**: PostgreSQL migration tests and slow tests are NOT in local default gates. They run in separate CI workflows. +**Note**: PostgreSQL migration tests and slow tests are NOT in local default +gates. They run in separate CI workflows. Frontend Playwright tests live under +`web/tests/e2e` and are documented in `web/README.md`. ### Developer Workflow @@ -28,6 +34,9 @@ make test-all-local bash scripts/test-quick.sh # ~2 min bash scripts/test-integration-fast.sh # ~3 min bash scripts/test-coverage.sh # ~8 min +uv run --python 3.12 pytest tests/e2e -q --tb=short +uv run --python 3.12 pytest tests/integration_tests -q --tb=short +cd web && pnpm test:e2e ``` ### Coverage Baseline @@ -70,6 +79,12 @@ tests/ │ └── persistence/ # Database/persistence tests │ ├── __init__.py │ └── test_migrations.py # Alembic migration tests +├── e2e/ # Real LangBot startup E2E tests +│ ├── conftest.py +│ ├── test_startup.py +│ └── utils/ +├── integration_tests/ # Container-backed integration tests +│ └── box/ # Box runtime and MCP process tests ├── smoke/ # Smoke tests (quick validation) │ └── test_fake_message_flow.py ├── unit_tests/ # Unit tests @@ -303,6 +318,44 @@ These tests: - Test prevent_default, exception handling, and full message flow - Do not require real LLM provider keys +### Running backend E2E startup tests + +Backend E2E tests start a real LangBot process with a generated minimal +`data/config.yaml`, SQLite database, local storage, and embedded Chroma path. +They do not require provider keys or external services. + +```bash +uv run --python 3.12 pytest tests/e2e -q --tb=short +``` + +These tests verify startup orchestration, migrations, API route registration, +and the minimal no-LLM startup path. The E2E process manager disables ambient +proxy variables for subprocess startup and uses direct localhost HTTP clients, +so local proxy settings should not affect the health checks. + +### Running Box integration tests + +Box integration tests exercise the real sandbox runtime path, including command +execution, session persistence, managed process WebSocket attachment, and +cleanup behavior. + +```bash +uv run --python 3.12 pytest tests/integration_tests -q --tb=short +``` + +These tests require a working Docker or Podman runtime. In CI, the dedicated +Box integration job checks Docker availability before running the tests. + +### Running frontend E2E tests + +Frontend E2E tests live in `web/tests/e2e` and use Playwright. They start Vite +and mock the LangBot backend and Space APIs, so no backend process is required. + +```bash +cd web +pnpm test:e2e +``` + ### Known Issues Some tests may encounter circular import errors. This is a known issue with the current module structure. The test infrastructure is designed to work around this using lazy imports, but if you encounter issues: @@ -320,6 +373,9 @@ Tests are automatically run on: - Push to master/develop branches The workflow runs tests on Python 3.11, 3.12, and 3.13 to ensure compatibility. +Startup E2E and Box integration tests run as separate Python 3.12 jobs because +they exercise process/container behavior instead of pure Python compatibility. +Frontend Playwright smoke tests run in `.github/workflows/frontend-tests.yml`. ## Adding New Tests @@ -406,4 +462,4 @@ Check that you're mocking at the right level and using `AsyncMock` for async fun - [ ] Add E2E tests - [ ] Add performance benchmarks - [ ] Add mutation testing for better coverage quality -- [ ] Add property-based testing with Hypothesis \ No newline at end of file +- [ ] Add property-based testing with Hypothesis diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 200ac22a8..ddef1abdc 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -92,11 +92,11 @@ def e2e_client(e2e_port, langbot_process): base_url = f'http://127.0.0.1:{e2e_port}' - with httpx.Client(base_url=base_url, timeout=10.0) as client: + with httpx.Client(base_url=base_url, timeout=10.0, trust_env=False) as client: yield client @pytest.fixture(scope='session') def e2e_db_path(e2e_tmpdir): """Path to SQLite database file.""" - return e2e_tmpdir / 'data' / 'langbot.db' \ No newline at end of file + return e2e_tmpdir / 'data' / 'langbot.db' diff --git a/tests/e2e/test_startup.py b/tests/e2e/test_startup.py index dcbe8e75f..e63150b4c 100644 --- a/tests/e2e/test_startup.py +++ b/tests/e2e/test_startup.py @@ -38,12 +38,13 @@ class TestStartupFlow: # System info should contain version info assert 'version' in data['data'] or 'edition' in data['data'] - def test_database_initialized(self, e2e_db_path): + def test_database_initialized(self, langbot_process, e2e_db_path): """Verify SQLite database was created and initialized.""" assert e2e_db_path.exists() # Database should have some tables after migration import sqlite3 + conn = sqlite3.connect(str(e2e_db_path)) cursor = conn.cursor() @@ -74,10 +75,13 @@ class TestStartupFlow: def test_auth_endpoint(self, e2e_client, e2e_tmpdir): """Test auth endpoint.""" # First startup may allow initial setup - response = e2e_client.post('/api/v1/user/auth', json={ - 'username': 'admin', - 'password': 'admin', - }) + response = e2e_client.post( + '/api/v1/user/auth', + json={ + 'user': 'admin', + 'password': 'admin', + }, + ) # Response could be: # - 200 if auth succeeds @@ -94,9 +98,10 @@ class TestStartupStages: # If API responds on e2e_port, config was loaded assert e2e_client.get('/api/v1/system/info').status_code == 200 - def test_migrations_applied(self, e2e_db_path): + def test_migrations_applied(self, langbot_process, e2e_db_path): """Verify database migrations were applied.""" import sqlite3 + conn = sqlite3.connect(str(e2e_db_path)) cursor = conn.cursor() diff --git a/tests/e2e/utils/config_factory.py b/tests/e2e/utils/config_factory.py index b838827cb..2df35b903 100644 --- a/tests/e2e/utils/config_factory.py +++ b/tests/e2e/utils/config_factory.py @@ -104,6 +104,17 @@ def create_minimal_config(tmpdir: Path, port: int = 15300) -> Path: 'user': 'postgres', 'password': 'postgres', }, + 'valkey_search': { + 'host': 'localhost', + 'port': 6379, + 'db': 0, + 'password': '', + 'username': '', + 'tls': False, + 'index_algorithm': 'HNSW', + 'distance_metric': 'COSINE', + 'request_timeout': 5000, + }, }, 'storage': { 'use': 'local', @@ -176,4 +187,4 @@ def create_test_directories(tmpdir: Path) -> dict[str, Path]: for path in directories.values(): path.mkdir(parents=True, exist_ok=True) - return directories \ No newline at end of file + return directories diff --git a/tests/e2e/utils/process_manager.py b/tests/e2e/utils/process_manager.py index 888b5dec8..44c6719e5 100644 --- a/tests/e2e/utils/process_manager.py +++ b/tests/e2e/utils/process_manager.py @@ -44,6 +44,17 @@ class LangBotProcess: # Prepare environment env = os.environ.copy() env['PYTHONPATH'] = str(self.project_root / 'src') + for proxy_key in ( + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'ALL_PROXY', + 'http_proxy', + 'https_proxy', + 'all_proxy', + ): + env.pop(proxy_key, None) + env['NO_PROXY'] = '127.0.0.1,localhost' + env['no_proxy'] = '127.0.0.1,localhost' # Set API port via environment variable env['API__PORT'] = str(self.port) @@ -79,9 +90,11 @@ precision = 2 f.write(coveragerc_content) cmd = [ - 'coverage', 'run', + 'coverage', + 'run', '--rcfile=' + str(coveragerc_path), - '-m', 'langbot', + '-m', + 'langbot', ] else: cmd = ['uv', 'run', 'python', '-m', 'langbot'] @@ -113,6 +126,8 @@ precision = 2 r = httpx.get( f'http://127.0.0.1:{self.port}/api/v1/system/info', timeout=2.0, + follow_redirects=False, + trust_env=False, ) if r.status_code == 200: logger.info(f'LangBot started successfully on port {self.port}') @@ -185,6 +200,8 @@ precision = 2 r = httpx.get( f'http://127.0.0.1:{self.port}/api/v1/system/info', timeout=5.0, + follow_redirects=False, + trust_env=False, ) return r.status_code == 200 except Exception: @@ -201,4 +218,4 @@ def find_project_root() -> Path: return parent # Fallback to LangBot-test-build directory - return Path('/home/glwuy/langbot-app/LangBot-test-build') \ No newline at end of file + return Path('/home/glwuy/langbot-app/LangBot-test-build') diff --git a/tests/factories/__init__.py b/tests/factories/__init__.py index 3a6e3d984..a6564c849 100644 --- a/tests/factories/__init__.py +++ b/tests/factories/__init__.py @@ -58,45 +58,45 @@ from tests.factories.platform import ( __all__ = [ # App - "FakeApp", - "fake_app", + 'FakeApp', + 'fake_app', # Message chains - "text_chain", - "group_text_chain", - "mention_chain", - "image_chain", + 'text_chain', + 'group_text_chain', + 'mention_chain', + 'image_chain', # Message events - "friend_message_event", - "group_message_event", + 'friend_message_event', + 'group_message_event', # Mock adapters - "mock_adapter", + 'mock_adapter', # Queries - "text_query", - "group_text_query", - "private_text_query", - "command_query", - "mention_query", - "empty_query", - "image_query", - "file_query", - "unsupported_query", - "voice_query", - "at_all_query", - "query_with_session", - "query_with_config", + 'text_query', + 'group_text_query', + 'private_text_query', + 'command_query', + 'mention_query', + 'empty_query', + 'image_query', + 'file_query', + 'unsupported_query', + 'voice_query', + 'at_all_query', + 'query_with_session', + 'query_with_config', # Provider - "FakeProvider", - "fake_provider", - "fake_provider_pong", - "fake_provider_timeout", - "fake_provider_auth_error", - "fake_provider_rate_limit", - "fake_provider_malformed", - "fake_model", + 'FakeProvider', + 'fake_provider', + 'fake_provider_pong', + 'fake_provider_timeout', + 'fake_provider_auth_error', + 'fake_provider_rate_limit', + 'fake_provider_malformed', + 'fake_model', # Platform - "FakePlatform", - "fake_platform", - "fake_platform_with_streaming", - "fake_platform_with_failure", - "mock_platform_adapter", -] \ No newline at end of file + 'FakePlatform', + 'fake_platform', + 'fake_platform_with_streaming', + 'fake_platform_with_failure', + 'mock_platform_adapter', +] diff --git a/tests/factories/app.py b/tests/factories/app.py index 5f36df846..d1edf56a2 100644 --- a/tests/factories/app.py +++ b/tests/factories/app.py @@ -15,7 +15,7 @@ class FakeApp: def __init__( self, *, - command_prefix: list[str] = ["/", "!"], + command_prefix: list[str] = ['/', '!'], command_enable: bool = True, pipeline_concurrency: int = 10, admins: list[str] | None = None, @@ -40,6 +40,8 @@ class FakeApp: self.telemetry = self._create_mock_telemetry() self.survey = None self.cmd_mgr = self._create_mock_cmd_mgr() + self.skill_mgr = self._create_mock_skill_mgr() + self.pipeline_service = self._create_mock_pipeline_service() # Apply any extra attributes for specific test scenarios for name, value in extra_attrs.items(): @@ -98,9 +100,9 @@ class FakeApp: ): instance_config = Mock() instance_config.data = { - "command": {"prefix": command_prefix, "enable": command_enable}, - "concurrency": {"pipeline": pipeline_concurrency}, - "admins": admins, + 'command': {'prefix': command_prefix, 'enable': command_enable}, + 'concurrency': {'pipeline': pipeline_concurrency}, + 'admins': admins, } return instance_config @@ -119,6 +121,20 @@ class FakeApp: cmd_mgr.execute = AsyncMock() return cmd_mgr + def _create_mock_skill_mgr(self): + """Mock SkillManager that returns no skill index addition by default.""" + skill_mgr = Mock() + skill_mgr.skills = {} + skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='') + skill_mgr.get_skill_index = Mock(return_value=[]) + return skill_mgr + + def _create_mock_pipeline_service(self): + """Mock PipelineService.get_pipeline returning empty extensions prefs.""" + pipeline_service = AsyncMock() + pipeline_service.get_pipeline = AsyncMock(return_value={'extensions_preferences': {}}) + return pipeline_service + def capture_message(self, message): """Capture an outbound message for test assertions.""" self._outbound_messages.append(message) @@ -134,4 +150,4 @@ class FakeApp: def fake_app(**kwargs) -> FakeApp: """Create a FakeApp instance with optional overrides.""" - return FakeApp(**kwargs) \ No newline at end of file + return FakeApp(**kwargs) diff --git a/tests/factories/message.py b/tests/factories/message.py index 8871c664a..9b3cc3602 100644 --- a/tests/factories/message.py +++ b/tests/factories/message.py @@ -30,32 +30,36 @@ def _next_query_id() -> int: # ============== Message Chain Factories ============== -def text_chain(text: str = "hello") -> platform_message.MessageChain: +def text_chain(text: str = 'hello') -> platform_message.MessageChain: """Create a simple text message chain.""" - return platform_message.MessageChain([ - platform_message.Plain(text=text), - ]) + return platform_message.MessageChain( + [ + platform_message.Plain(text=text), + ] + ) -def group_text_chain(text: str = "hello") -> platform_message.MessageChain: +def group_text_chain(text: str = 'hello') -> platform_message.MessageChain: """Create a group text message chain (same as text_chain, context provided by event).""" return text_chain(text) def mention_chain( - text: str = "hello", + text: str = 'hello', target: typing.Union[int, str] = 12345, ) -> platform_message.MessageChain: """Create a message chain with @mention.""" - return platform_message.MessageChain([ - platform_message.At(target=target), - platform_message.Plain(text=f" {text}"), - ]) + return platform_message.MessageChain( + [ + platform_message.At(target=target), + platform_message.Plain(text=f' {text}'), + ] + ) def image_chain( - text: str = "", - url: str = "https://example.com/image.png", + text: str = '', + url: str = 'https://example.com/image.png', ) -> platform_message.MessageChain: """Create a message chain with an image.""" components = [] @@ -66,13 +70,15 @@ def image_chain( def command_chain( - command: str = "help", - prefix: str = "/", + command: str = 'help', + prefix: str = '/', ) -> platform_message.MessageChain: """Create a command message chain.""" - return platform_message.MessageChain([ - platform_message.Plain(text=f"{prefix}{command}"), - ]) + return platform_message.MessageChain( + [ + platform_message.Plain(text=f'{prefix}{command}'), + ] + ) # ============== Message Event Factories ============== @@ -81,7 +87,7 @@ def command_chain( def friend_message_event( message_chain: platform_message.MessageChain, sender_id: typing.Union[int, str] = 12345, - nickname: str = "TestUser", + nickname: str = 'TestUser', ) -> platform_events.FriendMessage: """Create a friend (private) message event.""" sender = platform_entities.Friend( @@ -90,7 +96,7 @@ def friend_message_event( remark=None, ) return platform_events.FriendMessage( - type="FriendMessage", + type='FriendMessage', sender=sender, message_chain=message_chain, time=1609459200, @@ -100,9 +106,9 @@ def friend_message_event( def group_message_event( message_chain: platform_message.MessageChain, sender_id: typing.Union[int, str] = 12345, - sender_name: str = "TestUser", + sender_name: str = 'TestUser', group_id: typing.Union[int, str] = 99999, - group_name: str = "TestGroup", + group_name: str = 'TestGroup', ) -> platform_events.GroupMessage: """Create a group message event.""" group = platform_entities.Group( @@ -117,7 +123,7 @@ def group_message_event( group=group, ) return platform_events.GroupMessage( - type="GroupMessage", + type='GroupMessage', sender=sender, message_chain=message_chain, time=1609459200, @@ -152,36 +158,36 @@ def _base_query( query_id = _next_query_id() base_data = { - "query_id": query_id, - "launcher_type": launcher_type, - "launcher_id": launcher_id, - "sender_id": sender_id, - "message_chain": message_chain, - "message_event": message_event, - "adapter": adapter, - "pipeline_uuid": "test-pipeline-uuid", - "bot_uuid": "test-bot-uuid", - "pipeline_config": { - "ai": { - "runner": {"runner": "local-agent"}, - "local-agent": { - "model": {"primary": "test-model-uuid", "fallbacks": []}, - "prompt": "test-prompt", + 'query_id': query_id, + 'launcher_type': launcher_type, + 'launcher_id': launcher_id, + 'sender_id': sender_id, + 'message_chain': message_chain, + 'message_event': message_event, + 'adapter': adapter, + 'pipeline_uuid': 'test-pipeline-uuid', + 'bot_uuid': 'test-bot-uuid', + 'pipeline_config': { + 'ai': { + 'runner': {'runner': 'local-agent'}, + 'local-agent': { + 'model': {'primary': 'test-model-uuid', 'fallbacks': []}, + 'prompt': 'test-prompt', }, }, - "output": {"misc": {"at-sender": False, "quote-origin": False}}, - "trigger": {"misc": {"combine-quote-message": False}}, + 'output': {'misc': {'at-sender': False, 'quote-origin': False}}, + 'trigger': {'misc': {'combine-quote-message': False}}, }, - "session": None, - "prompt": None, - "messages": [], - "user_message": None, - "use_funcs": [], - "use_llm_model_uuid": None, - "variables": {}, - "resp_messages": [], - "resp_message_chain": None, - "current_stage_name": None, + 'session': None, + 'prompt': None, + 'messages': [], + 'user_message': None, + 'use_funcs': [], + 'use_llm_model_uuid': None, + 'variables': {}, + 'resp_messages': [], + 'resp_message_chain': None, + 'current_stage_name': None, } # Apply overrides @@ -192,7 +198,7 @@ def _base_query( def text_query( - text: str = "hello", + text: str = 'hello', sender_id: typing.Union[int, str] = 12345, **overrides, ) -> pipeline_query.Query: @@ -212,7 +218,7 @@ def text_query( def private_text_query( - text: str = "hello", + text: str = 'hello', sender_id: typing.Union[int, str] = 12345, **overrides, ) -> pipeline_query.Query: @@ -221,7 +227,7 @@ def private_text_query( def group_text_query( - text: str = "hello", + text: str = 'hello', sender_id: typing.Union[int, str] = 12345, group_id: typing.Union[int, str] = 99999, **overrides, @@ -242,8 +248,8 @@ def group_text_query( def command_query( - command: str = "help", - prefix: str = "/", + command: str = 'help', + prefix: str = '/', sender_id: typing.Union[int, str] = 12345, **overrides, ) -> pipeline_query.Query: @@ -263,7 +269,7 @@ def command_query( def mention_query( - text: str = "hello", + text: str = 'hello', target: typing.Union[int, str] = 12345, sender_id: typing.Union[int, str] = 12345, group_id: typing.Union[int, str] = 99999, @@ -301,8 +307,8 @@ def empty_query(**overrides) -> pipeline_query.Query: def image_query( - text: str = "", - url: str = "https://example.com/image.png", + text: str = '', + url: str = 'https://example.com/image.png', sender_id: typing.Union[int, str] = 12345, **overrides, ) -> pipeline_query.Query: @@ -322,9 +328,9 @@ def image_query( def file_query( - url: str = "https://example.com/document.pdf", - name: str = "document.pdf", - text: str = "", + url: str = 'https://example.com/document.pdf', + name: str = 'document.pdf', + text: str = '', sender_id: typing.Union[int, str] = 12345, **overrides, ) -> pipeline_query.Query: @@ -348,8 +354,8 @@ def file_query( def unsupported_query( - unsupported_type: str = "CustomComponent", - text: str = "", + unsupported_type: str = 'CustomComponent', + text: str = '', sender_id: typing.Union[int, str] = 12345, **overrides, ) -> pipeline_query.Query: @@ -358,7 +364,7 @@ def unsupported_query( if text: components.append(platform_message.Plain(text=text)) # Use Unknown component for unsupported types - components.append(platform_message.Unknown(text=f"Unsupported: {unsupported_type}")) + components.append(platform_message.Unknown(text=f'Unsupported: {unsupported_type}')) chain = platform_message.MessageChain(components) event = friend_message_event(chain, sender_id) adapter = mock_adapter() @@ -374,7 +380,7 @@ def unsupported_query( def query_with_session( - text: str = "hello", + text: str = 'hello', sender_id: typing.Union[int, str] = 12345, session: provider_session.Session = None, **overrides, @@ -389,7 +395,7 @@ def query_with_session( launcher_type=provider_session.LauncherTypes.PERSON, launcher_id=sender_id, sender_id=sender_id, - use_prompt_name="default", + use_prompt_name='default', using_conversation=None, conversations=[], ) @@ -398,7 +404,7 @@ def query_with_session( def query_with_config( - text: str = "hello", + text: str = 'hello', sender_id: typing.Union[int, str] = 12345, pipeline_config: dict = None, **overrides, @@ -410,22 +416,22 @@ def query_with_config( """ if pipeline_config is None: pipeline_config = { - "ai": { - "runner": {"runner": "local-agent"}, - "local-agent": { - "model": {"primary": "test-model-uuid", "fallbacks": []}, - "prompt": "test-prompt", + 'ai': { + 'runner': {'runner': 'local-agent'}, + 'local-agent': { + 'model': {'primary': 'test-model-uuid', 'fallbacks': []}, + 'prompt': 'test-prompt', }, }, - "output": {"misc": {"at-sender": False, "quote-origin": False}}, - "trigger": {"misc": {"combine-quote-message": False}}, + 'output': {'misc': {'at-sender': False, 'quote-origin': False}}, + 'trigger': {'misc': {'combine-quote-message': False}}, } return text_query(text, sender_id, pipeline_config=pipeline_config, **overrides) def voice_query( - url: str = "https://example.com/audio.mp3", + url: str = 'https://example.com/audio.mp3', sender_id: typing.Union[int, str] = 12345, **overrides, ) -> pipeline_query.Query: @@ -448,7 +454,7 @@ def voice_query( def at_all_query( - text: str = "hello", + text: str = 'hello', sender_id: typing.Union[int, str] = 12345, group_id: typing.Union[int, str] = 99999, **overrides, @@ -456,7 +462,7 @@ def at_all_query( """Create a group query with @All mention.""" components = [ platform_message.AtAll(), - platform_message.Plain(text=f" {text}"), + platform_message.Plain(text=f' {text}'), ] chain = platform_message.MessageChain(components) event = group_message_event(chain, sender_id, group_id=group_id) @@ -469,4 +475,4 @@ def at_all_query( sender_id=sender_id, adapter=adapter, **overrides, - ) \ No newline at end of file + ) diff --git a/tests/factories/platform.py b/tests/factories/platform.py index 725cead91..77b8f11f9 100644 --- a/tests/factories/platform.py +++ b/tests/factories/platform.py @@ -33,7 +33,7 @@ class FakePlatform: def __init__( self, *, - bot_account_id: str = "test-bot", + bot_account_id: str = 'test-bot', stream_output_supported: bool = False, raise_error: Exception = None, ): @@ -48,16 +48,16 @@ class FakePlatform: # Registered listeners self._listeners: dict = {} - def raises(self, error: Exception) -> "FakePlatform": + def raises(self, error: Exception) -> 'FakePlatform': """Configure platform to raise an error on send.""" self._raise_error = error return self - def send_failure(self) -> "FakePlatform": + def send_failure(self) -> 'FakePlatform': """Configure platform to simulate send failure.""" - return self.raises(Exception("Platform send failure")) + return self.raises(Exception('Platform send failure')) - def supports_streaming(self, supported: bool = True) -> "FakePlatform": + def supports_streaming(self, supported: bool = True) -> 'FakePlatform': """Configure whether streaming output is supported.""" self._stream_output_supported = supported return self @@ -89,7 +89,7 @@ class FakePlatform: self, text: str, sender_id: typing.Union[int, str] = 12345, - nickname: str = "TestUser", + nickname: str = 'TestUser', ) -> platform_events.FriendMessage: """Create an inbound friend (private) message event.""" sender = platform_entities.Friend( @@ -97,11 +97,13 @@ class FakePlatform: nickname=nickname, remark=None, ) - chain = platform_message.MessageChain([ - platform_message.Plain(text=text), - ]) + chain = platform_message.MessageChain( + [ + platform_message.Plain(text=text), + ] + ) return platform_events.FriendMessage( - type="FriendMessage", + type='FriendMessage', sender=sender, message_chain=chain, time=1609459200, @@ -111,9 +113,9 @@ class FakePlatform: self, text: str, sender_id: typing.Union[int, str] = 12345, - sender_name: str = "TestUser", + sender_name: str = 'TestUser', group_id: typing.Union[int, str] = 99999, - group_name: str = "TestGroup", + group_name: str = 'TestGroup', mention_bot: bool = False, ) -> platform_events.GroupMessage: """Create an inbound group message event. @@ -142,12 +144,12 @@ class FakePlatform: components = [] if mention_bot: components.append(platform_message.At(target=self.bot_account_id)) - components.append(platform_message.Plain(text=" ")) + components.append(platform_message.Plain(text=' ')) components.append(platform_message.Plain(text=text)) chain = platform_message.MessageChain(components) return platform_events.GroupMessage( - type="GroupMessage", + type='GroupMessage', sender=sender, message_chain=chain, time=1609459200, @@ -155,8 +157,8 @@ class FakePlatform: def create_image_message( self, - url: str = "https://example.com/image.png", - text: str = "", + url: str = 'https://example.com/image.png', + text: str = '', sender_id: typing.Union[int, str] = 12345, is_group: bool = False, group_id: typing.Union[int, str] = 99999, @@ -169,12 +171,12 @@ class FakePlatform: chain = platform_message.MessageChain(components) if is_group: - return self.create_group_message("", sender_id, group_id=group_id) + return self.create_group_message('', sender_id, group_id=group_id) # Replace chain else: - sender = platform_entities.Friend(id=sender_id, nickname="TestUser", remark=None) + sender = platform_entities.Friend(id=sender_id, nickname='TestUser', remark=None) return platform_events.FriendMessage( - type="FriendMessage", + type='FriendMessage', sender=sender, message_chain=chain, time=1609459200, @@ -192,12 +194,14 @@ class FakePlatform: if self._raise_error: raise self._raise_error - self._outbound_messages.append({ - "type": "send", - "target_type": target_type, - "target_id": target_id, - "message": message, - }) + self._outbound_messages.append( + { + 'type': 'send', + 'target_type': target_type, + 'target_id': target_id, + 'message': message, + } + ) async def reply_message( self, @@ -209,13 +213,15 @@ class FakePlatform: if self._raise_error: raise self._raise_error - self._outbound_messages.append({ - "type": "reply", - "source_type": message_source.type, - "source": message_source, - "message": message, - "quote_origin": quote_origin, - }) + self._outbound_messages.append( + { + 'type': 'reply', + 'source_type': message_source.type, + 'source': message_source, + 'message': message, + 'quote_origin': quote_origin, + } + ) async def reply_message_chunk( self, @@ -229,15 +235,17 @@ class FakePlatform: if self._raise_error: raise self._raise_error - self._outbound_chunks.append({ - "type": "reply_chunk", - "source_type": message_source.type, - "source": message_source, - "bot_message": bot_message, - "message": message, - "quote_origin": quote_origin, - "is_final": is_final, - }) + self._outbound_chunks.append( + { + 'type': 'reply_chunk', + 'source_type': message_source.type, + 'source': message_source, + 'bot_message': bot_message, + 'message': message, + 'quote_origin': quote_origin, + 'is_final': is_final, + } + ) async def is_stream_output_supported(self) -> bool: """Return whether streaming output is supported.""" @@ -295,7 +303,7 @@ class FakePlatform: def fake_platform( - bot_account_id: str = "test-bot", + bot_account_id: str = 'test-bot', stream_output_supported: bool = False, ) -> FakePlatform: """Create a FakePlatform instance.""" @@ -328,9 +336,7 @@ def mock_platform_adapter(platform: FakePlatform = None) -> Mock: adapter.reply_message = AsyncMock(side_effect=platform.reply_message) adapter.reply_message_chunk = AsyncMock(side_effect=platform.reply_message_chunk) adapter.send_message = AsyncMock(side_effect=platform.send_message) - adapter.is_stream_output_supported = AsyncMock( - return_value=platform._stream_output_supported - ) + adapter.is_stream_output_supported = AsyncMock(return_value=platform._stream_output_supported) adapter._fake_platform = platform # Store for assertions - return adapter \ No newline at end of file + return adapter diff --git a/tests/factories/provider.py b/tests/factories/provider.py index d50978549..a7f9d1384 100644 --- a/tests/factories/provider.py +++ b/tests/factories/provider.py @@ -27,51 +27,51 @@ class FakeProvider: Does not require API keys. """ - PONG_RESPONSE = "LANGBOT_FAKE_PONG" + PONG_RESPONSE = 'LANGBOT_FAKE_PONG' def __init__( self, *, - default_response: str = "fake response", + default_response: str = 'fake response', streaming_chunks: list[str] = None, raise_error: Exception = None, captured_requests: list = None, ): self._default_response = default_response - self._streaming_chunks = streaming_chunks or ["fake ", "response"] + self._streaming_chunks = streaming_chunks or ['fake ', 'response'] self._raise_error = raise_error self._captured_requests = captured_requests if captured_requests is not None else [] - def returns(self, text: str) -> "FakeProvider": + def returns(self, text: str) -> 'FakeProvider': """Configure provider to return a specific text response.""" self._default_response = text self._streaming_chunks = [text] return self - def returns_streaming(self, chunks: list[str]) -> "FakeProvider": + def returns_streaming(self, chunks: list[str]) -> 'FakeProvider': """Configure provider to return streaming chunks.""" self._streaming_chunks = chunks - self._default_response = "".join(chunks) + self._default_response = ''.join(chunks) return self - def raises(self, error: Exception) -> "FakeProvider": + def raises(self, error: Exception) -> 'FakeProvider': """Configure provider to raise an error.""" self._raise_error = error return self - def timeout(self) -> "FakeProvider": + def timeout(self) -> 'FakeProvider': """Configure provider to simulate timeout.""" - return self.raises(TimeoutError("Provider timeout")) + return self.raises(TimeoutError('Provider timeout')) - def auth_error(self) -> "FakeProvider": + def auth_error(self) -> 'FakeProvider': """Configure provider to simulate auth error.""" - return self.raises(Exception("Invalid API key")) + return self.raises(Exception('Invalid API key')) - def rate_limit(self) -> "FakeProvider": + def rate_limit(self) -> 'FakeProvider': """Configure provider to simulate rate limit.""" - return self.raises(Exception("Rate limit exceeded")) + return self.raises(Exception('Rate limit exceeded')) - def malformed(self) -> "FakeProvider": + def malformed(self) -> 'FakeProvider': """Configure provider to simulate malformed response.""" self._default_response = None return self @@ -87,7 +87,7 @@ class FakeProvider: def _create_message(self, content: str) -> provider_message.Message: """Create a provider message from text content.""" return provider_message.Message( - role="assistant", + role='assistant', content=content, ) @@ -99,7 +99,7 @@ class FakeProvider: ) -> provider_message.MessageChunk: """Create a provider message chunk.""" return provider_message.MessageChunk( - role="assistant", + role='assistant', content=content, is_final=is_final, msg_sequence=msg_sequence, @@ -116,13 +116,15 @@ class FakeProvider: ) -> provider_message.Message: """Simulate non-streaming LLM invocation.""" # Capture request for assertions - self._captured_requests.append({ - "query_id": query.query_id if query else None, - "model": model.model_entity.name if model and hasattr(model, 'model_entity') else None, - "messages": messages, - "funcs": funcs, - "extra_args": extra_args, - }) + self._captured_requests.append( + { + 'query_id': query.query_id if query else None, + 'model': model.model_entity.name if model and hasattr(model, 'model_entity') else None, + 'messages': messages, + 'funcs': funcs, + 'extra_args': extra_args, + } + ) # Simulate error if configured if self._raise_error: @@ -131,7 +133,7 @@ class FakeProvider: # Return response if self._default_response is None: # Malformed response - return provider_message.Message(role="assistant", content=None) + return provider_message.Message(role='assistant', content=None) return self._create_message(self._default_response) @@ -146,14 +148,16 @@ class FakeProvider: ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: """Simulate streaming LLM invocation.""" # Capture request for assertions - self._captured_requests.append({ - "query_id": query.query_id if query else None, - "model": model.model_entity.name if model and hasattr(model, 'model_entity') else None, - "messages": messages, - "funcs": funcs, - "extra_args": extra_args, - "streaming": True, - }) + self._captured_requests.append( + { + 'query_id': query.query_id if query else None, + 'model': model.model_entity.name if model and hasattr(model, 'model_entity') else None, + 'messages': messages, + 'funcs': funcs, + 'extra_args': extra_args, + 'streaming': True, + } + ) # Simulate error if configured if self._raise_error: @@ -161,12 +165,12 @@ class FakeProvider: # Yield chunks for i, chunk in enumerate(self._streaming_chunks): - is_final = (i == len(self._streaming_chunks) - 1) + is_final = i == len(self._streaming_chunks) - 1 yield self._create_chunk(chunk, is_final=is_final, msg_sequence=i) def fake_provider( - default_response: str = "fake response", + default_response: str = 'fake response', ) -> FakeProvider: """Create a FakeProvider with optional default response.""" return FakeProvider(default_response=default_response) @@ -202,8 +206,8 @@ def fake_provider_malformed() -> FakeProvider: def fake_model( *, - uuid: str = "test-model-uuid", - name: str = "test-model", + uuid: str = 'test-model-uuid', + name: str = 'test-model', abilities: list[str] = None, provider: FakeProvider = None, ) -> Mock: @@ -212,7 +216,7 @@ def fake_model( model.model_entity = Mock() model.model_entity.uuid = uuid model.model_entity.name = name - model.model_entity.abilities = abilities or ["func_call", "vision"] + model.model_entity.abilities = abilities or ['func_call', 'vision'] model.model_entity.extra_args = {} # Attach fake provider @@ -221,4 +225,4 @@ def fake_model( model.provider = provider - return model \ No newline at end of file + return model diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index a261bc7b8..dfc61335f 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -3,4 +3,4 @@ Integration tests package. These tests validate real system behavior with actual database/network resources. Run with: uv run pytest tests/integration/ -m "not slow" -q -""" \ No newline at end of file +""" diff --git a/tests/integration/api/__init__.py b/tests/integration/api/__init__.py index 999686642..f11571f07 100644 --- a/tests/integration/api/__init__.py +++ b/tests/integration/api/__init__.py @@ -2,4 +2,4 @@ API integration tests package. Tests for HTTP API endpoints using Quart test client. -""" \ No newline at end of file +""" diff --git a/tests/integration/api/test_bots.py b/tests/integration/api/test_bots.py index 578764ee0..0e6854bf9 100644 --- a/tests/integration/api/test_bots.py +++ b/tests/integration/api/test_bots.py @@ -48,6 +48,7 @@ def mock_circular_import_chain(): clear=clear, ): import langbot.pkg.api.http.controller.groups.platform.bots as _bots # noqa: E402, F401 + yield @@ -56,10 +57,12 @@ def fake_bot_app(): """Create FakeApp with bot services (module scope for reuse).""" app = FakeApp() - app.instance_config.data.update({ - 'api': {'port': 5300}, - 'system': {'allow_modify_login_info': True, 'limitation': {}}, - }) + app.instance_config.data.update( + { + 'api': {'port': 5300}, + 'system': {'allow_modify_login_info': True, 'limitation': {}}, + } + ) # Auth services app.user_service = Mock() @@ -71,28 +74,29 @@ def fake_bot_app(): # Bot service app.bot_service = Mock() - app.bot_service.get_bots = AsyncMock(return_value=[ - { + app.bot_service.get_bots = AsyncMock( + return_value=[ + { + 'uuid': 'test-bot-uuid', + 'name': 'Test Bot', + 'platform': 'telegram', + 'pipeline_uuid': 'test-pipeline-uuid', + } + ] + ) + app.bot_service.get_runtime_bot_info = AsyncMock( + return_value={ 'uuid': 'test-bot-uuid', 'name': 'Test Bot', 'platform': 'telegram', 'pipeline_uuid': 'test-pipeline-uuid', + 'webhook_url': 'https://example.com/webhook/test-bot-uuid', } - ]) - app.bot_service.get_runtime_bot_info = AsyncMock(return_value={ - 'uuid': 'test-bot-uuid', - 'name': 'Test Bot', - 'platform': 'telegram', - 'pipeline_uuid': 'test-pipeline-uuid', - 'webhook_url': 'https://example.com/webhook/test-bot-uuid', - }) + ) app.bot_service.create_bot = AsyncMock(return_value={'uuid': 'new-bot-uuid'}) app.bot_service.update_bot = AsyncMock(return_value={}) app.bot_service.delete_bot = AsyncMock() - app.bot_service.list_event_logs = AsyncMock(return_value=( - [{'uuid': 'log-1', 'message': 'test log'}], - 1 - )) + app.bot_service.list_event_logs = AsyncMock(return_value=([{'uuid': 'log-1', 'message': 'test log'}], 1)) app.bot_service.send_message = AsyncMock() # Platform manager @@ -118,10 +122,7 @@ class TestBotEndpoints: @pytest.mark.asyncio async def test_get_bots_success(self, quart_test_client): """GET /api/v1/platform/bots returns bot list.""" - response = await quart_test_client.get( - '/api/v1/platform/bots', - headers={'Authorization': 'Bearer test_token'} - ) + response = await quart_test_client.get('/api/v1/platform/bots', headers={'Authorization': 'Bearer test_token'}) assert response.status_code == 200 data = await response.get_json() @@ -135,7 +136,7 @@ class TestBotEndpoints: response = await quart_test_client.post( '/api/v1/platform/bots', headers={'Authorization': 'Bearer test_token'}, - json={'name': 'New Bot', 'platform': 'telegram', 'pipeline_uuid': 'test-pipeline'} + json={'name': 'New Bot', 'platform': 'telegram', 'pipeline_uuid': 'test-pipeline'}, ) assert response.status_code == 200 @@ -147,8 +148,7 @@ class TestBotEndpoints: async def test_get_single_bot_success(self, quart_test_client): """GET /api/v1/platform/bots/{uuid} returns bot with runtime info.""" response = await quart_test_client.get( - '/api/v1/platform/bots/test-bot-uuid', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/platform/bots/test-bot-uuid', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -162,7 +162,7 @@ class TestBotEndpoints: response = await quart_test_client.put( '/api/v1/platform/bots/test-bot-uuid', headers={'Authorization': 'Bearer test_token'}, - json={'name': 'Updated Bot'} + json={'name': 'Updated Bot'}, ) assert response.status_code == 200 @@ -173,8 +173,7 @@ class TestBotEndpoints: async def test_delete_bot_success(self, quart_test_client): """DELETE /api/v1/platform/bots/{uuid} deletes bot.""" response = await quart_test_client.delete( - '/api/v1/platform/bots/test-bot-uuid', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/platform/bots/test-bot-uuid', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -190,7 +189,7 @@ class TestBotLogsEndpoint: response = await quart_test_client.post( '/api/v1/platform/bots/test-bot-uuid/logs', headers={'Authorization': 'Bearer test_token'}, - json={'from_index': -1, 'max_count': 10} + json={'from_index': -1, 'max_count': 10}, ) assert response.status_code == 200 @@ -213,8 +212,8 @@ class TestBotSendMessageEndpoint: json={ 'target_type': 'person', 'target_id': 'user123', - 'message_chain': [{'type': 'text', 'text': 'Hello'}] - } + 'message_chain': [{'type': 'text', 'text': 'Hello'}], + }, ) assert response.status_code == 200 @@ -228,7 +227,7 @@ class TestBotSendMessageEndpoint: response = await quart_test_client.post( '/api/v1/platform/bots/test-bot-uuid/send_message', headers={'Authorization': 'Bearer test_api_key'}, - json={'target_id': 'user123', 'message_chain': [{'type': 'text', 'text': 'Hello'}]} + json={'target_id': 'user123', 'message_chain': [{'type': 'text', 'text': 'Hello'}]}, ) assert response.status_code == 400 @@ -244,8 +243,8 @@ class TestBotSendMessageEndpoint: json={ 'target_type': 'invalid', 'target_id': 'user123', - 'message_chain': [{'type': 'text', 'text': 'Hello'}] - } + 'message_chain': [{'type': 'text', 'text': 'Hello'}], + }, ) assert response.status_code == 400 diff --git a/tests/integration/api/test_embed.py b/tests/integration/api/test_embed.py index 12d53d42c..5d034d133 100644 --- a/tests/integration/api/test_embed.py +++ b/tests/integration/api/test_embed.py @@ -47,6 +47,7 @@ def mock_circular_import_chain(): clear=clear, ): import langbot.pkg.api.http.controller.groups.pipelines.embed as _embed # noqa: E402, F401 + yield @@ -55,10 +56,12 @@ def fake_embed_app(): """Create FakeApp with embed widget services (module scope).""" app = FakeApp() - app.instance_config.data.update({ - 'api': {'port': 5300}, - 'system': {'allow_modify_login_info': True, 'limitation': {}}, - }) + app.instance_config.data.update( + { + 'api': {'port': 5300}, + 'system': {'allow_modify_login_info': True, 'limitation': {}}, + } + ) # Create mock web_page_bot with valid UUID format mock_bot_entity = Mock() @@ -83,9 +86,7 @@ def fake_embed_app(): # WebSocket proxy bot with adapter mock_websocket_adapter = Mock() - mock_websocket_adapter.get_websocket_messages = Mock(return_value=[ - {'id': 'msg-1', 'content': 'test message'} - ]) + mock_websocket_adapter.get_websocket_messages = Mock(return_value=[{'id': 'msg-1', 'content': 'test message'}]) mock_websocket_adapter.reset_session = Mock() mock_websocket_adapter.handle_websocket_message = AsyncMock() @@ -117,9 +118,7 @@ class TestEmbedWidgetEndpoint: @pytest.mark.asyncio async def test_get_widget_js_success(self, quart_test_client): """GET /api/v1/embed/{bot_uuid}/widget.js returns JS.""" - response = await quart_test_client.get( - '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/widget.js' - ) + response = await quart_test_client.get('/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/widget.js') assert response.status_code == 200 assert 'javascript' in response.content_type @@ -127,18 +126,14 @@ class TestEmbedWidgetEndpoint: @pytest.mark.asyncio async def test_get_widget_js_invalid_uuid(self, quart_test_client): """GET widget.js with invalid UUID returns 400.""" - response = await quart_test_client.get( - '/api/v1/embed/invalid-uuid/widget.js' - ) + response = await quart_test_client.get('/api/v1/embed/invalid-uuid/widget.js') assert response.status_code == 400 @pytest.mark.asyncio async def test_get_widget_js_bot_not_found(self, quart_test_client): """GET widget.js for non-existent bot returns 404.""" - response = await quart_test_client.get( - '/api/v1/embed/00000000-0000-0000-0000-000000000000/widget.js' - ) + response = await quart_test_client.get('/api/v1/embed/00000000-0000-0000-0000-000000000000/widget.js') assert response.status_code == 404 @@ -164,8 +159,7 @@ class TestEmbedTurnstileVerifyEndpoint: async def test_turnstile_verify_no_secret(self, quart_test_client): """POST turnstile verify without secret returns dummy token.""" response = await quart_test_client.post( - '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/turnstile/verify', - json={'token': 'test-token'} + '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/turnstile/verify', json={'token': 'test-token'} ) assert response.status_code == 200 @@ -177,8 +171,7 @@ class TestEmbedTurnstileVerifyEndpoint: async def test_turnstile_verify_invalid_uuid(self, quart_test_client): """POST turnstile verify with invalid UUID returns 400.""" response = await quart_test_client.post( - '/api/v1/embed/invalid-uuid/turnstile/verify', - json={'token': 'test-token'} + '/api/v1/embed/invalid-uuid/turnstile/verify', json={'token': 'test-token'} ) assert response.status_code == 400 @@ -187,8 +180,7 @@ class TestEmbedTurnstileVerifyEndpoint: async def test_turnstile_verify_missing_token(self, quart_test_client): """POST turnstile verify without token returns 400.""" response = await quart_test_client.post( - '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/turnstile/verify', - json={} + '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/turnstile/verify', json={} ) assert response.status_code == 400 @@ -203,7 +195,7 @@ class TestEmbedMessagesEndpoint: """GET messages/person returns messages.""" response = await quart_test_client.get( '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/person', - headers={'Authorization': 'Bearer 1234567890.dummy'} + headers={'Authorization': 'Bearer 1234567890.dummy'}, ) assert response.status_code == 200 @@ -216,7 +208,7 @@ class TestEmbedMessagesEndpoint: """GET messages/group returns messages.""" response = await quart_test_client.get( '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/group', - headers={'Authorization': 'Bearer 1234567890.dummy'} + headers={'Authorization': 'Bearer 1234567890.dummy'}, ) assert response.status_code == 200 @@ -226,7 +218,7 @@ class TestEmbedMessagesEndpoint: """GET messages with invalid session_type returns 400.""" response = await quart_test_client.get( '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/invalid', - headers={'Authorization': 'Bearer 1234567890.dummy'} + headers={'Authorization': 'Bearer 1234567890.dummy'}, ) assert response.status_code == 400 @@ -241,7 +233,7 @@ class TestEmbedResetEndpoint: """POST reset/person resets session.""" response = await quart_test_client.post( '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/reset/person', - headers={'Authorization': 'Bearer 1234567890.dummy'} + headers={'Authorization': 'Bearer 1234567890.dummy'}, ) assert response.status_code == 200 @@ -252,8 +244,7 @@ class TestEmbedResetEndpoint: async def test_reset_session_invalid_uuid(self, quart_test_client): """POST reset with invalid UUID returns 400.""" response = await quart_test_client.post( - '/api/v1/embed/invalid-uuid/reset/person', - headers={'Authorization': 'Bearer 1234567890.dummy'} + '/api/v1/embed/invalid-uuid/reset/person', headers={'Authorization': 'Bearer 1234567890.dummy'} ) assert response.status_code == 400 @@ -269,7 +260,7 @@ class TestEmbedFeedbackEndpoint: response = await quart_test_client.post( '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback', headers={'Authorization': 'Bearer 1234567890.dummy'}, - json={'message_id': 'msg-123', 'feedback_type': 1} + json={'message_id': 'msg-123', 'feedback_type': 1}, ) assert response.status_code == 200 @@ -283,7 +274,7 @@ class TestEmbedFeedbackEndpoint: response = await quart_test_client.post( '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback', headers={'Authorization': 'Bearer 1234567890.dummy'}, - json={'message_id': 'msg-123', 'feedback_type': 2} + json={'message_id': 'msg-123', 'feedback_type': 2}, ) assert response.status_code == 200 @@ -294,7 +285,7 @@ class TestEmbedFeedbackEndpoint: response = await quart_test_client.post( '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback', headers={'Authorization': 'Bearer 1234567890.dummy'}, - json={'message_id': 'msg-123', 'feedback_type': 99} + json={'message_id': 'msg-123', 'feedback_type': 99}, ) assert response.status_code == 400 diff --git a/tests/integration/api/test_knowledge.py b/tests/integration/api/test_knowledge.py index 9c6935fbb..973356c3e 100644 --- a/tests/integration/api/test_knowledge.py +++ b/tests/integration/api/test_knowledge.py @@ -49,6 +49,7 @@ def mock_circular_import_chain(): clear=clear, ): import langbot.pkg.api.http.controller.groups.knowledge.base as _knowledge # noqa: E402, F401 + yield @@ -57,10 +58,12 @@ def fake_knowledge_app(): """Create FakeApp with knowledge services (module scope for reuse).""" app = FakeApp() - app.instance_config.data.update({ - 'api': {'port': 5300}, - 'system': {'allow_modify_login_info': True, 'limitation': {}}, - }) + app.instance_config.data.update( + { + 'api': {'port': 5300}, + 'system': {'allow_modify_login_info': True, 'limitation': {}}, + } + ) # Auth services app.user_service = Mock() @@ -72,33 +75,35 @@ def fake_knowledge_app(): # Knowledge service app.knowledge_service = Mock() - app.knowledge_service.get_knowledge_bases = AsyncMock(return_value=[ - { + app.knowledge_service.get_knowledge_bases = AsyncMock( + return_value=[ + { + 'uuid': 'test-kb-uuid', + 'name': 'Test Knowledge Base', + 'description': 'Test KB description', + 'engine_plugin_id': 'test/engine', + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + } + ] + ) + app.knowledge_service.get_knowledge_base = AsyncMock( + return_value={ 'uuid': 'test-kb-uuid', 'name': 'Test Knowledge Base', 'description': 'Test KB description', 'engine_plugin_id': 'test/engine', - 'created_at': '2024-01-01T00:00:00', - 'updated_at': '2024-01-01T00:00:00', } - ]) - app.knowledge_service.get_knowledge_base = AsyncMock(return_value={ - 'uuid': 'test-kb-uuid', - 'name': 'Test Knowledge Base', - 'description': 'Test KB description', - 'engine_plugin_id': 'test/engine', - }) + ) app.knowledge_service.create_knowledge_base = AsyncMock(return_value={'uuid': 'new-kb-uuid'}) app.knowledge_service.update_knowledge_base = AsyncMock(return_value={}) app.knowledge_service.delete_knowledge_base = AsyncMock() - app.knowledge_service.get_files_by_knowledge_base = AsyncMock(return_value=[ - {'uuid': 'test-file-uuid', 'filename': 'test.pdf'} - ]) + app.knowledge_service.get_files_by_knowledge_base = AsyncMock( + return_value=[{'uuid': 'test-file-uuid', 'filename': 'test.pdf'}] + ) app.knowledge_service.store_file = AsyncMock(return_value={'task_id': 'test-task-id'}) app.knowledge_service.delete_file = AsyncMock() - app.knowledge_service.retrieve_knowledge_base = AsyncMock(return_value=[ - {'content': 'test result', 'score': 0.95} - ]) + app.knowledge_service.retrieve_knowledge_base = AsyncMock(return_value=[{'content': 'test result', 'score': 0.95}]) # RAG manager app.rag_mgr = Mock() @@ -124,8 +129,7 @@ class TestKnowledgeBaseEndpoints: async def test_get_knowledge_bases_success(self, quart_test_client): """GET /api/v1/knowledge/bases returns knowledge base list.""" response = await quart_test_client.get( - '/api/v1/knowledge/bases', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/knowledge/bases', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -140,7 +144,7 @@ class TestKnowledgeBaseEndpoints: response = await quart_test_client.post( '/api/v1/knowledge/bases', headers={'Authorization': 'Bearer test_token'}, - json={'name': 'New KB', 'engine_plugin_id': 'test/engine'} + json={'name': 'New KB', 'engine_plugin_id': 'test/engine'}, ) assert response.status_code == 200 @@ -152,8 +156,7 @@ class TestKnowledgeBaseEndpoints: async def test_get_single_knowledge_base_success(self, quart_test_client): """GET /api/v1/knowledge/bases/{uuid} returns knowledge base.""" response = await quart_test_client.get( - '/api/v1/knowledge/bases/test-kb-uuid', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/knowledge/bases/test-kb-uuid', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -167,7 +170,7 @@ class TestKnowledgeBaseEndpoints: response = await quart_test_client.put( '/api/v1/knowledge/bases/test-kb-uuid', headers={'Authorization': 'Bearer test_token'}, - json={'name': 'Updated KB'} + json={'name': 'Updated KB'}, ) assert response.status_code == 200 @@ -178,8 +181,7 @@ class TestKnowledgeBaseEndpoints: async def test_delete_knowledge_base_success(self, quart_test_client): """DELETE /api/v1/knowledge/bases/{uuid} deletes knowledge base.""" response = await quart_test_client.delete( - '/api/v1/knowledge/bases/test-kb-uuid', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/knowledge/bases/test-kb-uuid', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -193,8 +195,7 @@ class TestKnowledgeBaseFilesEndpoints: async def test_get_files_success(self, quart_test_client): """GET /api/v1/knowledge/bases/{uuid}/files returns files.""" response = await quart_test_client.get( - '/api/v1/knowledge/bases/test-kb-uuid/files', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/knowledge/bases/test-kb-uuid/files', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -208,7 +209,7 @@ class TestKnowledgeBaseFilesEndpoints: response = await quart_test_client.post( '/api/v1/knowledge/bases/test-kb-uuid/files', headers={'Authorization': 'Bearer test_token'}, - json={'file_id': 'test-file-id', 'parser_plugin_id': 'test/parser'} + json={'file_id': 'test-file-id', 'parser_plugin_id': 'test/parser'}, ) assert response.status_code == 200 @@ -220,8 +221,7 @@ class TestKnowledgeBaseFilesEndpoints: async def test_delete_file_from_knowledge_base(self, quart_test_client): """DELETE /api/v1/knowledge/bases/{uuid}/files/{file_id}.""" response = await quart_test_client.delete( - '/api/v1/knowledge/bases/test-kb-uuid/files/test-file-uuid', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/knowledge/bases/test-kb-uuid/files/test-file-uuid', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -237,7 +237,7 @@ class TestKnowledgeBaseRetrieveEndpoint: response = await quart_test_client.post( '/api/v1/knowledge/bases/test-kb-uuid/retrieve', headers={'Authorization': 'Bearer test_token'}, - json={'query': 'test query', 'retrieval_settings': {'top_k': 5}} + json={'query': 'test query', 'retrieval_settings': {'top_k': 5}}, ) assert response.status_code == 200 @@ -249,9 +249,7 @@ class TestKnowledgeBaseRetrieveEndpoint: async def test_retrieve_without_query_returns_error(self, quart_test_client): """POST retrieve without query returns 400.""" response = await quart_test_client.post( - '/api/v1/knowledge/bases/test-kb-uuid/retrieve', - headers={'Authorization': 'Bearer test_token'}, - json={} + '/api/v1/knowledge/bases/test-kb-uuid/retrieve', headers={'Authorization': 'Bearer test_token'}, json={} ) assert response.status_code == 400 diff --git a/tests/integration/api/test_monitoring.py b/tests/integration/api/test_monitoring.py index 8291bcd13..64c155840 100644 --- a/tests/integration/api/test_monitoring.py +++ b/tests/integration/api/test_monitoring.py @@ -46,6 +46,7 @@ def mock_circular_import_chain(): clear=clear, ): import langbot.pkg.api.http.controller.groups.monitoring as _monitoring # noqa: E402, F401 + yield @@ -54,10 +55,12 @@ def fake_monitoring_app(): """Create FakeApp with monitoring services (module scope).""" app = FakeApp() - app.instance_config.data.update({ - 'api': {'port': 5300}, - 'system': {'allow_modify_login_info': True, 'limitation': {}}, - }) + app.instance_config.data.update( + { + 'api': {'port': 5300}, + 'system': {'allow_modify_login_info': True, 'limitation': {}}, + } + ) # Auth services - USER_TOKEN auth requires jwt verification AND get_user_by_email app.user_service = Mock() @@ -67,40 +70,35 @@ def fake_monitoring_app(): # Monitoring service app.monitoring_service = Mock() - app.monitoring_service.get_overview_metrics = AsyncMock(return_value={ - 'total_messages': 100, - 'total_llm_calls': 50, - 'total_sessions': 20, - 'active_sessions': 5, - 'total_errors': 2, - }) - app.monitoring_service.get_messages = AsyncMock(return_value=( - [{'id': 'msg-1', 'content': 'test'}], 100 - )) - app.monitoring_service.get_llm_calls = AsyncMock(return_value=( - [{'id': 'llm-1'}], 50 - )) - app.monitoring_service.get_embedding_calls = AsyncMock(return_value=( - [{'id': 'emb-1'}], 10 - )) - app.monitoring_service.get_sessions = AsyncMock(return_value=( - [{'session_id': 'sess-1'}], 20 - )) - app.monitoring_service.get_errors = AsyncMock(return_value=( - [{'id': 'err-1'}], 2 - )) - app.monitoring_service.get_session_analysis = AsyncMock(return_value={ - 'found': True, - 'session_id': 'sess-1', - }) - app.monitoring_service.get_message_details = AsyncMock(return_value={ - 'found': True, - 'message_id': 'msg-1', - }) + app.monitoring_service.get_overview_metrics = AsyncMock( + return_value={ + 'total_messages': 100, + 'total_llm_calls': 50, + 'total_sessions': 20, + 'active_sessions': 5, + 'total_errors': 2, + } + ) + app.monitoring_service.get_messages = AsyncMock(return_value=([{'id': 'msg-1', 'content': 'test'}], 100)) + app.monitoring_service.get_llm_calls = AsyncMock(return_value=([{'id': 'llm-1'}], 50)) + app.monitoring_service.get_tool_calls = AsyncMock(return_value=([{'id': 'tool-1'}], 5)) + app.monitoring_service.get_embedding_calls = AsyncMock(return_value=([{'id': 'emb-1'}], 10)) + app.monitoring_service.get_sessions = AsyncMock(return_value=([{'session_id': 'sess-1'}], 20)) + app.monitoring_service.get_errors = AsyncMock(return_value=([{'id': 'err-1'}], 2)) + app.monitoring_service.get_session_analysis = AsyncMock( + return_value={ + 'found': True, + 'session_id': 'sess-1', + } + ) + app.monitoring_service.get_message_details = AsyncMock( + return_value={ + 'found': True, + 'message_id': 'msg-1', + } + ) app.monitoring_service.get_feedback_stats = AsyncMock(return_value={'like_count': 10}) - app.monitoring_service.get_feedback_list = AsyncMock(return_value=( - [{'feedback_id': 'fb-1'}], 12 - )) + app.monitoring_service.get_feedback_list = AsyncMock(return_value=([{'feedback_id': 'fb-1'}], 12)) app.monitoring_service.export_messages = AsyncMock(return_value=[{'id': 'msg-1'}]) app.monitoring_service.export_llm_calls = AsyncMock(return_value=[{'id': 'llm-1'}]) app.monitoring_service.export_errors = AsyncMock(return_value=[{'id': 'err-1'}]) @@ -130,8 +128,7 @@ class TestMonitoringOverviewEndpoint: async def test_get_overview_success(self, quart_test_client): """GET /api/v1/monitoring/overview returns metrics.""" response = await quart_test_client.get( - '/api/v1/monitoring/overview', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/overview', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -147,8 +144,7 @@ class TestMonitoringMessagesEndpoint: async def test_get_messages_success(self, quart_test_client): """GET /api/v1/monitoring/messages returns message list.""" response = await quart_test_client.get( - '/api/v1/monitoring/messages', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/messages', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -165,8 +161,7 @@ class TestMonitoringLLMCallsEndpoint: async def test_get_llm_calls_success(self, quart_test_client): """GET /api/v1/monitoring/llm-calls.""" response = await quart_test_client.get( - '/api/v1/monitoring/llm-calls', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/llm-calls', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -180,8 +175,7 @@ class TestMonitoringEmbeddingCallsEndpoint: async def test_get_embedding_calls_success(self, quart_test_client): """GET /api/v1/monitoring/embedding-calls.""" response = await quart_test_client.get( - '/api/v1/monitoring/embedding-calls', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/embedding-calls', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -195,8 +189,7 @@ class TestMonitoringSessionsEndpoint: async def test_get_sessions_success(self, quart_test_client): """GET /api/v1/monitoring/sessions.""" response = await quart_test_client.get( - '/api/v1/monitoring/sessions', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/sessions', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -210,8 +203,7 @@ class TestMonitoringErrorsEndpoint: async def test_get_errors_success(self, quart_test_client): """GET /api/v1/monitoring/errors.""" response = await quart_test_client.get( - '/api/v1/monitoring/errors', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/errors', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -225,8 +217,7 @@ class TestMonitoringAllDataEndpoint: async def test_get_all_data_success(self, quart_test_client): """GET /api/v1/monitoring/data returns all data.""" response = await quart_test_client.get( - '/api/v1/monitoring/data', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/data', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -242,8 +233,7 @@ class TestMonitoringDetailsEndpoints: async def test_get_session_analysis(self, quart_test_client): """GET /api/v1/monitoring/sessions/{id}/analysis.""" response = await quart_test_client.get( - '/api/v1/monitoring/sessions/sess-1/analysis', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/sessions/sess-1/analysis', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -252,8 +242,7 @@ class TestMonitoringDetailsEndpoints: async def test_get_message_details(self, quart_test_client): """GET /api/v1/monitoring/messages/{id}/details.""" response = await quart_test_client.get( - '/api/v1/monitoring/messages/msg-1/details', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/messages/msg-1/details', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -267,8 +256,7 @@ class TestMonitoringFeedbackEndpoints: async def test_get_feedback_stats(self, quart_test_client): """GET /api/v1/monitoring/feedback/stats.""" response = await quart_test_client.get( - '/api/v1/monitoring/feedback/stats', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/feedback/stats', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -277,8 +265,7 @@ class TestMonitoringFeedbackEndpoints: async def test_get_feedback_list(self, quart_test_client): """GET /api/v1/monitoring/feedback.""" response = await quart_test_client.get( - '/api/v1/monitoring/feedback', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/feedback', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -292,8 +279,7 @@ class TestMonitoringExportEndpoint: async def test_export_messages(self, quart_test_client): """GET export?type=messages returns CSV.""" response = await quart_test_client.get( - '/api/v1/monitoring/export?type=messages', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/export?type=messages', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -303,8 +289,7 @@ class TestMonitoringExportEndpoint: async def test_export_llm_calls(self, quart_test_client): """GET export?type=llm-calls returns CSV.""" response = await quart_test_client.get( - '/api/v1/monitoring/export?type=llm-calls', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/export?type=llm-calls', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -313,8 +298,7 @@ class TestMonitoringExportEndpoint: async def test_export_sessions(self, quart_test_client): """GET export?type=sessions returns CSV.""" response = await quart_test_client.get( - '/api/v1/monitoring/export?type=sessions', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/export?type=sessions', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -323,8 +307,7 @@ class TestMonitoringExportEndpoint: async def test_export_feedback(self, quart_test_client): """GET export?type=feedback returns CSV.""" response = await quart_test_client.get( - '/api/v1/monitoring/export?type=feedback', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/export?type=feedback', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 diff --git a/tests/integration/api/test_pipelines.py b/tests/integration/api/test_pipelines.py index 502b12c2e..50ac37bc5 100644 --- a/tests/integration/api/test_pipelines.py +++ b/tests/integration/api/test_pipelines.py @@ -20,6 +20,7 @@ pytestmark = pytest.mark.integration # ============== FIXTURE FOR SYS.MODULES ISOLATION ============== + @pytest.fixture(scope='module') def mock_circular_import_chain(): """Break circular import chain for API controller.""" @@ -53,21 +54,25 @@ def mock_circular_import_chain(): ): # Import groups after mocking to populate preregistered_groups import langbot.pkg.api.http.controller.groups.pipelines.pipelines as _pipelines # noqa: E402, F401 + yield # ============== FAKE APPLICATION WITH PIPELINE SERVICES ============== + @pytest.fixture(scope='module') def fake_pipeline_app(): """Create FakeApp with pipeline-specific services (module scope for reuse).""" app = FakeApp() # Pipeline config - app.instance_config.data.update({ - 'api': {'port': 5300}, - 'system': {'allow_modify_login_info': True, 'limitation': {}}, - }) + app.instance_config.data.update( + { + 'api': {'port': 5300}, + 'system': {'allow_modify_login_info': True, 'limitation': {}}, + } + ) # Auth services app.user_service = Mock() @@ -79,25 +84,31 @@ def fake_pipeline_app(): # Pipeline service app.pipeline_service = Mock() - app.pipeline_service.get_pipeline_metadata = AsyncMock(return_value=[ - {'name': 'trigger', 'stages': []}, - {'name': 'ai', 'stages': []}, - ]) - app.pipeline_service.get_pipelines = AsyncMock(return_value=[ - { + app.pipeline_service.get_pipeline_metadata = AsyncMock( + return_value=[ + {'name': 'trigger', 'stages': []}, + {'name': 'ai', 'stages': []}, + ] + ) + app.pipeline_service.get_pipelines = AsyncMock( + return_value=[ + { + 'uuid': 'test-pipeline-uuid', + 'name': 'Test Pipeline', + 'description': 'Test description', + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + 'is_default': False, + } + ] + ) + app.pipeline_service.get_pipeline = AsyncMock( + return_value={ 'uuid': 'test-pipeline-uuid', 'name': 'Test Pipeline', - 'description': 'Test description', - 'created_at': '2024-01-01T00:00:00', - 'updated_at': '2024-01-01T00:00:00', - 'is_default': False, + 'config': {}, } - ]) - app.pipeline_service.get_pipeline = AsyncMock(return_value={ - 'uuid': 'test-pipeline-uuid', - 'name': 'Test Pipeline', - 'config': {}, - }) + ) app.pipeline_service.create_pipeline = AsyncMock(return_value={'uuid': 'new-pipeline-uuid'}) app.pipeline_service.update_pipeline = AsyncMock(return_value={}) app.pipeline_service.delete_pipeline = AsyncMock() @@ -112,6 +123,10 @@ def fake_pipeline_app(): app.mcp_service = Mock() app.mcp_service.get_mcp_servers = AsyncMock(return_value=[]) + # Skill service (for extensions endpoint) + app.skill_service = Mock() + app.skill_service.list_skills = AsyncMock(return_value=[]) + # Plugin connector (for extensions endpoint) app.plugin_connector.list_plugins = AsyncMock(return_value=[]) @@ -130,6 +145,7 @@ async def quart_test_client(fake_pipeline_app, http_controller_cls): # ============== PIPELINE ENDPOINT TESTS ============== + @pytest.mark.usefixtures('mock_circular_import_chain') class TestPipelineMetadataEndpoint: """Tests for /api/v1/pipelines/_/metadata endpoint.""" @@ -138,8 +154,7 @@ class TestPipelineMetadataEndpoint: async def test_get_pipeline_metadata_success(self, quart_test_client): """GET /api/v1/pipelines/_/metadata returns metadata list.""" response = await quart_test_client.get( - '/api/v1/pipelines/_/metadata', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/pipelines/_/metadata', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -162,10 +177,7 @@ class TestPipelinesListEndpoint: @pytest.mark.asyncio async def test_get_pipelines_success(self, quart_test_client): """GET /api/v1/pipelines returns pipeline list.""" - response = await quart_test_client.get( - '/api/v1/pipelines', - headers={'Authorization': 'Bearer test_token'} - ) + response = await quart_test_client.get('/api/v1/pipelines', headers={'Authorization': 'Bearer test_token'}) assert response.status_code == 200 data = await response.get_json() @@ -176,8 +188,7 @@ class TestPipelinesListEndpoint: async def test_get_pipelines_with_sort_param(self, quart_test_client): """GET pipelines with sort parameter.""" response = await quart_test_client.get( - '/api/v1/pipelines?sort_by=created_at&sort_order=DESC', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/pipelines?sort_by=created_at&sort_order=DESC', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -193,8 +204,7 @@ class TestPipelinesCRUDEndpoints: async def test_get_single_pipeline_success(self, quart_test_client): """GET /api/v1/pipelines/{uuid} returns pipeline.""" response = await quart_test_client.get( - '/api/v1/pipelines/test-pipeline-uuid', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -208,7 +218,7 @@ class TestPipelinesCRUDEndpoints: response = await quart_test_client.post( '/api/v1/pipelines', headers={'Authorization': 'Bearer test_token'}, - json={'name': 'New Pipeline', 'config': {}} + json={'name': 'New Pipeline', 'config': {}}, ) assert response.status_code == 200 @@ -222,7 +232,7 @@ class TestPipelinesCRUDEndpoints: response = await quart_test_client.put( '/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'}, - json={'name': 'Updated Pipeline'} + json={'name': 'Updated Pipeline'}, ) assert response.status_code == 200 @@ -233,8 +243,7 @@ class TestPipelinesCRUDEndpoints: async def test_delete_pipeline_success(self, quart_test_client): """DELETE /api/v1/pipelines/{uuid} deletes pipeline.""" response = await quart_test_client.delete( - '/api/v1/pipelines/test-pipeline-uuid', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -245,8 +254,7 @@ class TestPipelinesCRUDEndpoints: async def test_copy_pipeline_success(self, quart_test_client): """POST /api/v1/pipelines/{uuid}/copy copies pipeline.""" response = await quart_test_client.post( - '/api/v1/pipelines/test-pipeline-uuid/copy', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/pipelines/test-pipeline-uuid/copy', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -263,8 +271,7 @@ class TestPipelineExtensionsEndpoint: async def test_get_extensions(self, quart_test_client): """GET /api/v1/pipelines/{uuid}/extensions.""" response = await quart_test_client.get( - '/api/v1/pipelines/test-pipeline-uuid/extensions', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/pipelines/test-pipeline-uuid/extensions', headers={'Authorization': 'Bearer test_token'} ) # Should return 200 if pipeline found diff --git a/tests/integration/api/test_providers.py b/tests/integration/api/test_providers.py index 4dfa862e8..a42a99428 100644 --- a/tests/integration/api/test_providers.py +++ b/tests/integration/api/test_providers.py @@ -49,6 +49,7 @@ def mock_circular_import_chain(): ): import langbot.pkg.api.http.controller.groups.provider.providers as _providers # noqa: E402, F401 import langbot.pkg.api.http.controller.groups.provider.models as _models # noqa: E402, F401 + yield @@ -57,10 +58,12 @@ def fake_provider_app(): """Create FakeApp with provider/model services (module scope for reuse).""" app = FakeApp() - app.instance_config.data.update({ - 'api': {'port': 5300}, - 'system': {'allow_modify_login_info': True, 'limitation': {}}, - }) + app.instance_config.data.update( + { + 'api': {'port': 5300}, + 'system': {'allow_modify_login_info': True, 'limitation': {}}, + } + ) # Auth services app.user_service = Mock() @@ -72,27 +75,23 @@ def fake_provider_app(): # Provider service app.provider_service = Mock() - app.provider_service.get_providers = AsyncMock(return_value=[ - {'uuid': 'test-provider-uuid', 'name': 'OpenAI', 'requester': 'chatcmpl'} - ]) - app.provider_service.get_provider = AsyncMock(return_value={ - 'uuid': 'test-provider-uuid', 'name': 'OpenAI', 'requester': 'chatcmpl' - }) + app.provider_service.get_providers = AsyncMock( + return_value=[{'uuid': 'test-provider-uuid', 'name': 'OpenAI', 'requester': 'chatcmpl'}] + ) + app.provider_service.get_provider = AsyncMock( + return_value={'uuid': 'test-provider-uuid', 'name': 'OpenAI', 'requester': 'chatcmpl'} + ) app.provider_service.create_provider = AsyncMock(return_value='new-provider-uuid') app.provider_service.update_provider = AsyncMock(return_value={}) app.provider_service.delete_provider = AsyncMock() - app.provider_service.get_provider_model_counts = AsyncMock(return_value={ - 'llm_count': 2, 'embedding_count': 1, 'rerank_count': 0 - }) + app.provider_service.get_provider_model_counts = AsyncMock( + return_value={'llm_count': 2, 'embedding_count': 1, 'rerank_count': 0} + ) # LLM model service app.llm_model_service = Mock() - app.llm_model_service.get_llm_models = AsyncMock(return_value=[ - {'uuid': 'test-model-uuid', 'name': 'gpt-4'} - ]) - app.llm_model_service.get_llm_model = AsyncMock(return_value={ - 'uuid': 'test-model-uuid', 'name': 'gpt-4' - }) + app.llm_model_service.get_llm_models = AsyncMock(return_value=[{'uuid': 'test-model-uuid', 'name': 'gpt-4'}]) + app.llm_model_service.get_llm_model = AsyncMock(return_value={'uuid': 'test-model-uuid', 'name': 'gpt-4'}) app.llm_model_service.create_llm_model = AsyncMock(return_value={'uuid': 'new-model-uuid'}) app.llm_model_service.update_llm_model = AsyncMock(return_value={}) app.llm_model_service.delete_llm_model = AsyncMock() @@ -133,8 +132,7 @@ class TestProviderEndpoints: async def test_get_providers_success(self, quart_test_client): """GET /api/v1/provider/providers returns provider list with complete structure.""" response = await quart_test_client.get( - '/api/v1/provider/providers', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/provider/providers', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -157,8 +155,7 @@ class TestProviderEndpoints: async def test_get_single_provider_success(self, quart_test_client): """GET /api/v1/provider/providers/{uuid} returns complete provider structure.""" response = await quart_test_client.get( - '/api/v1/provider/providers/test-provider-uuid', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -177,7 +174,7 @@ class TestProviderEndpoints: response = await quart_test_client.post( '/api/v1/provider/providers', headers={'Authorization': 'Bearer test_token'}, - json={'name': 'New Provider', 'requester': 'chatcmpl'} + json={'name': 'New Provider', 'requester': 'chatcmpl'}, ) assert response.status_code == 200 @@ -194,7 +191,7 @@ class TestProviderEndpoints: response = await quart_test_client.put( '/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'}, - json={'name': 'Updated Provider'} + json={'name': 'Updated Provider'}, ) assert response.status_code == 200 @@ -205,8 +202,7 @@ class TestProviderEndpoints: async def test_delete_provider_success(self, quart_test_client): """DELETE /api/v1/provider/providers/{uuid} deletes provider.""" response = await quart_test_client.delete( - '/api/v1/provider/providers/test-provider-uuid', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -215,8 +211,7 @@ class TestProviderEndpoints: async def test_get_provider_includes_model_counts(self, quart_test_client): """GET provider response includes model counts.""" response = await quart_test_client.get( - '/api/v1/provider/providers/test-provider-uuid', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -237,8 +232,7 @@ class TestModelEndpoints: async def test_get_llm_models_success(self, quart_test_client): """GET /api/v1/provider/models/llm returns model list.""" response = await quart_test_client.get( - '/api/v1/provider/models/llm', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/provider/models/llm', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -250,8 +244,7 @@ class TestModelEndpoints: async def test_get_single_llm_model_success(self, quart_test_client): """GET /api/v1/provider/models/llm/{uuid} returns model.""" response = await quart_test_client.get( - '/api/v1/provider/models/llm/test-model-uuid', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/provider/models/llm/test-model-uuid', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -264,7 +257,7 @@ class TestModelEndpoints: response = await quart_test_client.post( '/api/v1/provider/models/llm', headers={'Authorization': 'Bearer test_token'}, - json={'name': 'New Model', 'provider_uuid': 'test-provider-uuid'} + json={'name': 'New Model', 'provider_uuid': 'test-provider-uuid'}, ) assert response.status_code == 200 @@ -276,8 +269,7 @@ class TestModelEndpoints: async def test_delete_llm_model_success(self, quart_test_client): """DELETE /api/v1/provider/models/llm/{uuid} deletes model.""" response = await quart_test_client.delete( - '/api/v1/provider/models/llm/test-model-uuid', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/provider/models/llm/test-model-uuid', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -291,8 +283,7 @@ class TestEmbeddingModelEndpoints: async def test_get_embedding_models_success(self, quart_test_client): """GET /api/v1/provider/models/embedding returns model list.""" response = await quart_test_client.get( - '/api/v1/provider/models/embedding', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/provider/models/embedding', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -306,7 +297,7 @@ class TestEmbeddingModelEndpoints: response = await quart_test_client.post( '/api/v1/provider/models/embedding', headers={'Authorization': 'Bearer test_token'}, - json={'name': 'New Embedding Model', 'provider_uuid': 'test-provider-uuid'} + json={'name': 'New Embedding Model', 'provider_uuid': 'test-provider-uuid'}, ) assert response.status_code == 200 @@ -323,8 +314,7 @@ class TestRerankModelEndpoints: async def test_get_rerank_models_success(self, quart_test_client): """GET /api/v1/provider/models/rerank returns model list.""" response = await quart_test_client.get( - '/api/v1/provider/models/rerank', - headers={'Authorization': 'Bearer test_token'} + '/api/v1/provider/models/rerank', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 @@ -338,7 +328,7 @@ class TestRerankModelEndpoints: response = await quart_test_client.post( '/api/v1/provider/models/rerank', headers={'Authorization': 'Bearer test_token'}, - json={'name': 'New Rerank Model', 'provider_uuid': 'test-provider-uuid'} + json={'name': 'New Rerank Model', 'provider_uuid': 'test-provider-uuid'}, ) assert response.status_code == 200 diff --git a/tests/integration/api/test_smoke.py b/tests/integration/api/test_smoke.py index 460db55bd..9f611bb6c 100644 --- a/tests/integration/api/test_smoke.py +++ b/tests/integration/api/test_smoke.py @@ -20,6 +20,7 @@ pytestmark = pytest.mark.integration # ============== FIXTURE FOR SYS.MODULES ISOLATION ============== + @pytest.fixture(scope='module') def mock_circular_import_chain(): """ @@ -69,6 +70,7 @@ def mock_circular_import_chain(): # ============== FAKE APPLICATION FOR API TESTS ============== + @pytest.fixture def fake_api_app(): """ @@ -79,12 +81,14 @@ def fake_api_app(): app = FakeApp() # API-specific config - app.instance_config.data.update({ - 'api': {'port': 5300}, - 'plugin': {'enable_marketplace': True}, - 'space': {'url': 'https://space.langbot.app'}, - 'system': {'allow_modify_login_info': True, 'limitation': {}}, - }) + app.instance_config.data.update( + { + 'api': {'port': 5300}, + 'plugin': {'enable_marketplace': True}, + 'space': {'url': 'https://space.langbot.app'}, + 'system': {'allow_modify_login_info': True, 'limitation': {}}, + } + ) # API-specific services app.user_service = Mock() @@ -118,6 +122,7 @@ def fake_api_app(): # ============== QUART TEST CLIENT FIXTURE ============== + @pytest.fixture async def quart_test_client(fake_api_app, http_controller_cls): """ @@ -135,6 +140,7 @@ async def quart_test_client(fake_api_app, http_controller_cls): # ============== API SMOKE TESTS ============== + @pytest.mark.usefixtures('mock_circular_import_chain') class TestHealthEndpoint: """Tests for /healthz endpoint - simplest smoke test.""" @@ -222,8 +228,7 @@ class TestProtectedEndpoints: Protected endpoint returns 401 with invalid token. """ response = await quart_test_client.get( - '/api/v1/user/check-token', - headers={'Authorization': 'Bearer invalid_token'} + '/api/v1/user/check-token', headers={'Authorization': 'Bearer invalid_token'} ) assert response.status_code == 401 @@ -254,10 +259,7 @@ class TestInvalidPayload: """ POST with wrong JSON structure returns stable error. """ - response = await quart_test_client.post( - '/api/v1/user/auth', - json={'wrong_field': 'value'} - ) + response = await quart_test_client.post('/api/v1/user/auth', json={'wrong_field': 'value'}) # Should return error with stable JSON structure assert response.status_code in (400, 500, 401) diff --git a/tests/integration/persistence/__init__.py b/tests/integration/persistence/__init__.py index 496ef8684..0cab56344 100644 --- a/tests/integration/persistence/__init__.py +++ b/tests/integration/persistence/__init__.py @@ -2,4 +2,4 @@ Persistence integration tests package. Tests for database migrations and storage behavior. -""" \ No newline at end of file +""" diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index 944b45246..25d3e5c82 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -17,7 +17,21 @@ from langbot.pkg.persistence.alembic_runner import ( run_alembic_upgrade, run_alembic_stamp, get_alembic_current, + _ALEMBIC_DIR, ) +from alembic.config import Config +from alembic.script import ScriptDirectory + + +def _get_script_head() -> str: + """Resolve the current Alembic head revision from the script directory. + + Avoids hardcoding a revision number in assertions so adding a new + migration doesn't require editing the migration tests. + """ + cfg = Config() + cfg.set_main_option('script_location', _ALEMBIC_DIR) + return ScriptDirectory.from_config(cfg).get_current_head() pytestmark = pytest.mark.integration @@ -26,8 +40,8 @@ pytestmark = pytest.mark.integration @pytest.fixture def sqlite_db_url(tmp_path): """Create SQLite URL with temporary database file.""" - db_file = tmp_path / "test_migrations.db" - return f"sqlite+aiosqlite:///{db_file}" + db_file = tmp_path / 'test_migrations.db' + return f'sqlite+aiosqlite:///{db_file}' @pytest.fixture @@ -102,9 +116,11 @@ class TestSQLiteMigrationUpgrade: # Verify revision rev = await get_alembic_current(sqlite_engine) - assert rev is not None, "Expected a revision after upgrade" - # Head should be the latest migration - assert rev.startswith('0003'), f"Expected head to be 0003_*, got {rev}" + assert rev is not None, 'Expected a revision after upgrade' + # Head should be the latest migration. Resolve the actual head from the + # Alembic script directory instead of hardcoding a revision number, so + # adding a new migration doesn't require editing this assertion. + assert rev == _get_script_head(), f'Expected head {_get_script_head()}, got {rev}' @pytest.mark.asyncio async def test_upgrade_idempotent(self, sqlite_engine): @@ -131,7 +147,7 @@ class TestSQLiteMigrationUpgrade: await run_alembic_upgrade(sqlite_engine, 'head') rev2 = await get_alembic_current(sqlite_engine) - assert rev2 == rev1, f"Expected {rev1}, got {rev2}" + assert rev2 == rev1, f'Expected {rev1}, got {rev2}' class TestSQLiteMigrationFreshDatabase: @@ -149,8 +165,8 @@ class TestSQLiteMigrationFreshDatabase: 4. Verify revision """ # Use different DB file for fresh test - fresh_db_file = tmp_path / "test_migrations_fresh.db" - fresh_url = f"sqlite+aiosqlite:///{fresh_db_file}" + fresh_db_file = tmp_path / 'test_migrations_fresh.db' + fresh_url = f'sqlite+aiosqlite:///{fresh_db_file}' fresh_engine = create_async_engine(fresh_url) # Create tables on fresh DB @@ -162,7 +178,7 @@ class TestSQLiteMigrationFreshDatabase: # Verify revision rev = await get_alembic_current(fresh_engine) - assert rev is not None, "Expected a revision on fresh DB" + assert rev is not None, 'Expected a revision on fresh DB' await fresh_engine.dispose() @@ -181,8 +197,8 @@ class TestSQLiteMigrationFreshDatabase: IMPORTANT: This test verifies the ACTUAL behavior, not accepting any arbitrary failure with try-except pass. """ - fresh_db_file = tmp_path / "test_empty_migrations.db" - fresh_url = f"sqlite+aiosqlite:///{fresh_db_file}" + fresh_db_file = tmp_path / 'test_empty_migrations.db' + fresh_url = f'sqlite+aiosqlite:///{fresh_db_file}' fresh_engine = create_async_engine(fresh_url) # Capture the actual behavior @@ -201,23 +217,23 @@ class TestSQLiteMigrationFreshDatabase: # Verify specific behavior - one of two outcomes is expected if actual_result is not None: # Migration succeeded - verify revision exists - assert actual_result is not None, "Revision should exist after successful migration" + assert actual_result is not None, 'Revision should exist after successful migration' else: # Migration failed - verify the error type is known # Alembic typically raises specific errors for missing tables - assert actual_error is not None, "Error should be captured if migration failed" + assert actual_error is not None, 'Error should be captured if migration failed' # Log the error type for documentation (don't silently pass) error_type = type(actual_error).__name__ # Acceptable error types for empty DB scenarios acceptable_errors = [ 'OperationalError', # SQLite table not found 'ProgrammingError', # SQLAlchemy errors - 'CommandError', # Alembic command errors + 'CommandError', # Alembic command errors ] assert error_type in acceptable_errors, ( - f"Unexpected error type: {error_type}. " - f"This may indicate a regression in migration behavior. " - f"Error: {actual_error}" + f'Unexpected error type: {error_type}. ' + f'This may indicate a regression in migration behavior. ' + f'Error: {actual_error}' ) @@ -235,7 +251,7 @@ class TestSQLiteMigrationGetCurrent: # No stamp - should return None rev = await get_alembic_current(sqlite_engine) - assert rev is None, f"Expected None for unstamped DB, got {rev}" + assert rev is None, f'Expected None for unstamped DB, got {rev}' @pytest.mark.asyncio async def test_get_current_after_stamp_returns_revision(self, sqlite_engine): @@ -248,4 +264,4 @@ class TestSQLiteMigrationGetCurrent: await run_alembic_stamp(sqlite_engine, '0001_baseline') rev = await get_alembic_current(sqlite_engine) - assert rev == '0001_baseline' \ No newline at end of file + assert rev == '0001_baseline' diff --git a/tests/integration/persistence/test_migrations_postgres.py b/tests/integration/persistence/test_migrations_postgres.py index 33233897f..7eee23785 100644 --- a/tests/integration/persistence/test_migrations_postgres.py +++ b/tests/integration/persistence/test_migrations_postgres.py @@ -23,7 +23,21 @@ from langbot.pkg.persistence.alembic_runner import ( run_alembic_upgrade, run_alembic_stamp, get_alembic_current, + _ALEMBIC_DIR, ) +from alembic.config import Config +from alembic.script import ScriptDirectory + + +def _get_script_head() -> str: + """Resolve the current Alembic head revision from the script directory. + + Avoids hardcoding a revision number in assertions so adding a new + migration doesn't require editing the migration tests. + """ + cfg = Config() + cfg.set_main_option('script_location', _ALEMBIC_DIR) + return ScriptDirectory.from_config(cfg).get_current_head() pytestmark = [pytest.mark.integration, pytest.mark.slow] @@ -34,14 +48,14 @@ def postgres_url(): """Get PostgreSQL URL from environment.""" url = os.environ.get('TEST_POSTGRES_URL') if not url: - pytest.skip("TEST_POSTGRES_URL not set") + pytest.skip('TEST_POSTGRES_URL not set') return url @pytest.fixture async def postgres_engine(postgres_url): """Create async PostgreSQL engine.""" - engine = create_async_engine(postgres_url, isolation_level="AUTOCOMMIT") + engine = create_async_engine(postgres_url, isolation_level='AUTOCOMMIT') yield engine await engine.dispose() @@ -66,7 +80,7 @@ async def clean_alembic_version(postgres_engine): async with postgres_engine.begin() as conn: # Drop alembic_version table if exists try: - await conn.execute(text("DROP TABLE IF EXISTS alembic_version")) + await conn.execute(text('DROP TABLE IF EXISTS alembic_version')) except Exception: pass @@ -74,7 +88,7 @@ async def clean_alembic_version(postgres_engine): async with postgres_engine.begin() as conn: try: - await conn.execute(text("DROP TABLE IF EXISTS alembic_version")) + await conn.execute(text('DROP TABLE IF EXISTS alembic_version')) except Exception: pass @@ -83,9 +97,7 @@ class TestPostgreSQLMigrationBaseline: """Tests for baseline stamp workflow on PostgreSQL.""" @pytest.mark.asyncio - async def test_postgres_baseline_stamp_sets_revision( - self, postgres_engine, clean_tables, clean_alembic_version - ): + async def test_postgres_baseline_stamp_sets_revision(self, postgres_engine, clean_tables, clean_alembic_version): """ Stamp baseline on existing tables sets correct revision. @@ -106,9 +118,7 @@ class TestPostgreSQLMigrationBaseline: assert rev == '0001_baseline', f"Expected '0001_baseline', got {rev}" @pytest.mark.asyncio - async def test_postgres_baseline_stamp_on_empty_db( - self, postgres_engine, clean_tables, clean_alembic_version - ): + async def test_postgres_baseline_stamp_on_empty_db(self, postgres_engine, clean_tables, clean_alembic_version): """ Stamp on empty database (no tables) still sets revision. @@ -125,9 +135,7 @@ class TestPostgreSQLMigrationUpgrade: """Tests for upgrade to head workflow on PostgreSQL.""" @pytest.mark.asyncio - async def test_postgres_upgrade_from_baseline_to_head( - self, postgres_engine, clean_tables, clean_alembic_version - ): + async def test_postgres_upgrade_from_baseline_to_head(self, postgres_engine, clean_tables, clean_alembic_version): """ Upgrade from baseline to head applies all migrations. @@ -149,14 +157,14 @@ class TestPostgreSQLMigrationUpgrade: # Verify revision rev = await get_alembic_current(postgres_engine) - assert rev is not None, "Expected a revision after upgrade" - # Head should be the latest migration (0003 for current state) - assert rev.startswith('0003'), f"Expected head to be 0003_*, got {rev}" + assert rev is not None, 'Expected a revision after upgrade' + # Head should be the latest migration. Resolve the actual head from the + # Alembic script directory instead of hardcoding a revision number, so + # adding a new migration doesn't require editing this assertion. + assert rev == _get_script_head(), f'Expected head {_get_script_head()}, got {rev}' @pytest.mark.asyncio - async def test_postgres_upgrade_idempotent( - self, postgres_engine, clean_tables, clean_alembic_version - ): + async def test_postgres_upgrade_idempotent(self, postgres_engine, clean_tables, clean_alembic_version): """ Running upgrade to head multiple times is idempotent. @@ -180,7 +188,7 @@ class TestPostgreSQLMigrationUpgrade: await run_alembic_upgrade(postgres_engine, 'head') rev2 = await get_alembic_current(postgres_engine) - assert rev2 == rev1, f"Expected {rev1}, got {rev2}" + assert rev2 == rev1, f'Expected {rev1}, got {rev2}' class TestPostgreSQLMigrationGetCurrent: @@ -199,7 +207,7 @@ class TestPostgreSQLMigrationGetCurrent: # No stamp - should return None rev = await get_alembic_current(postgres_engine) - assert rev is None, f"Expected None for unstamped DB, got {rev}" + assert rev is None, f'Expected None for unstamped DB, got {rev}' @pytest.mark.asyncio async def test_postgres_get_current_after_stamp_returns_revision( @@ -214,4 +222,4 @@ class TestPostgreSQLMigrationGetCurrent: await run_alembic_stamp(postgres_engine, '0001_baseline') rev = await get_alembic_current(postgres_engine) - assert rev == '0001_baseline' \ No newline at end of file + assert rev == '0001_baseline' diff --git a/tests/integration/pipeline/__init__.py b/tests/integration/pipeline/__init__.py index 9351eaba7..7cb13296e 100644 --- a/tests/integration/pipeline/__init__.py +++ b/tests/integration/pipeline/__init__.py @@ -2,4 +2,4 @@ Pipeline integration tests package. Tests for full pipeline flow using fake provider/runner. -""" \ No newline at end of file +""" diff --git a/tests/integration/pipeline/test_full_flow.py b/tests/integration/pipeline/test_full_flow.py index 08acce4cc..767594c33 100644 --- a/tests/integration/pipeline/test_full_flow.py +++ b/tests/integration/pipeline/test_full_flow.py @@ -26,6 +26,7 @@ pytestmark = pytest.mark.integration # ============== FIXTURE FOR SYS.MODULES ISOLATION ============== + @pytest.fixture(scope='module') def mock_circular_import_chain(): """ @@ -103,6 +104,7 @@ def mock_circular_import_chain(): # ============== FAKE RUNNER ============== + class FakeRunner: """Minimal fake runner class for pipeline integration tests. @@ -117,12 +119,13 @@ class FakeRunner: self.config = config or {} self._provider = FakeProvider() # Instance-level configuration set via class attribute - self._response_text = "fake response" + self._response_text = 'fake response' self._raise_error = None @classmethod def returns(cls, text: str): """Create a runner class configured to return specific text.""" + # We create a subclass with configured response class ConfiguredRunner(cls): name = cls.name @@ -132,11 +135,13 @@ class FakeRunner: def __init__(self, app=None, config=None): super().__init__(app, config) self._response_text = text + return ConfiguredRunner @classmethod def raises(cls, error: Exception): """Create a runner class configured to raise an error.""" + class ConfiguredRunner(cls): name = cls.name _response_text = None @@ -145,6 +150,7 @@ class FakeRunner: def __init__(self, app=None, config=None): super().__init__(app, config) self._raise_error = error + return ConfiguredRunner async def run(self, query): @@ -161,6 +167,7 @@ class FakeRunner: # ============== PIPELINE APP FIXTURE ============== + @pytest.fixture def pipeline_app(): """ @@ -187,6 +194,7 @@ def pipeline_app(): def __init__(self, name, messages): self.name = name self.messages = messages + def copy(self): return MockPrompt(self.name, list(self.messages)) @@ -237,14 +245,17 @@ def fake_platform_adapter(): @pytest.fixture def set_fake_runner(): """Factory fixture to set a fake runner CLASS in preregistered_runners.""" + def _set_runner(runner_cls): # preregistered_runners expects a list of runner classes sys.modules['langbot.pkg.provider.runner'].preregistered_runners = [runner_cls] + return _set_runner # ============== PIPELINE CONFIGURATION ============== + def create_minimal_pipeline_config(): """Create minimal pipeline configuration for tests.""" return { @@ -273,6 +284,7 @@ def create_minimal_pipeline_config(): # ============== HELPER TO PROCESS COROUTINE/GENERATOR ============== + async def collect_processor_results(processor, query, stage_name): """ Helper to handle the coroutine -> async_generator pattern. @@ -296,6 +308,7 @@ async def collect_processor_results(processor, query, stage_name): # ============== TESTS ============== + @pytest.mark.usefixtures('mock_circular_import_chain') class TestPipelineStageChainReal: """Tests for real pipeline stage chain.""" @@ -337,7 +350,7 @@ class TestPreProcessorStage: adapter, platform = fake_platform_adapter # Create query with adapter - query = text_query("hello") + query = text_query('hello') query.adapter = adapter query.pipeline_config = create_minimal_pipeline_config() @@ -365,7 +378,7 @@ class TestPreProcessorStage: adapter, platform = fake_platform_adapter - query = text_query("test message content") + query = text_query('test message content') query.adapter = adapter query.pipeline_config = create_minimal_pipeline_config() @@ -396,11 +409,11 @@ class TestProcessorStage: adapter, platform = fake_platform_adapter # Set fake runner that returns pong - fake_runner = FakeRunner().returns("LANGBOT_FAKE_PONG") + fake_runner = FakeRunner().returns('LANGBOT_FAKE_PONG') set_fake_runner(fake_runner) # Create query - query = text_query("hello") + query = text_query('hello') query.adapter = adapter query.pipeline_config = create_minimal_pipeline_config() query.resp_messages = [] @@ -414,6 +427,7 @@ class TestProcessorStage: # Create Processor stage from langbot.pkg.pipeline.process import process + processor_stage = process.Processor(pipeline_app) await processor_stage.initialize(query.pipeline_config) @@ -432,7 +446,7 @@ class TestProcessorStage: adapter, platform = fake_platform_adapter # Create query - query = text_query("hello") + query = text_query('hello') query.adapter = adapter query.pipeline_config = create_minimal_pipeline_config() @@ -445,6 +459,7 @@ class TestProcessorStage: # Create Processor stage from langbot.pkg.pipeline.process import process + processor_stage = process.Processor(pipeline_app) await processor_stage.initialize(query.pipeline_config) @@ -462,13 +477,13 @@ class TestProcessorStage: adapter, platform = fake_platform_adapter # Create query - query = text_query("hello") + query = text_query('hello') query.adapter = adapter query.pipeline_config = create_minimal_pipeline_config() query.resp_messages = [] # Create reply chain - reply_chain = text_chain("plugin response") + reply_chain = text_chain('plugin response') # Mock plugin_connector to prevent default with reply mock_event_ctx = Mock() @@ -479,6 +494,7 @@ class TestProcessorStage: # Create Processor stage from langbot.pkg.pipeline.process import process + processor_stage = process.Processor(pipeline_app) await processor_stage.initialize(query.pipeline_config) @@ -502,7 +518,7 @@ class TestRunnerExceptionFlow: adapter, platform = fake_platform_adapter # Set fake runner that raises exception - fake_runner = FakeRunner().raises(ValueError("API Error: rate limit exceeded")) + fake_runner = FakeRunner().raises(ValueError('API Error: rate limit exceeded')) set_fake_runner(fake_runner) # Create query with exception handling config @@ -510,7 +526,7 @@ class TestRunnerExceptionFlow: config['output']['misc']['exception-handling'] = 'show-hint' config['output']['misc']['failure-hint'] = 'Request failed.' - query = text_query("hello") + query = text_query('hello') query.adapter = adapter query.pipeline_config = config @@ -523,6 +539,7 @@ class TestRunnerExceptionFlow: # Create Processor stage from langbot.pkg.pipeline.process import process + processor_stage = process.Processor(pipeline_app) await processor_stage.initialize(query.pipeline_config) @@ -541,14 +558,14 @@ class TestRunnerExceptionFlow: adapter, platform = fake_platform_adapter # Set fake runner that raises specific exception - fake_runner = FakeRunner().raises(RuntimeError("Custom runtime error")) + fake_runner = FakeRunner().raises(RuntimeError('Custom runtime error')) set_fake_runner(fake_runner) # Create query with show-error mode config = create_minimal_pipeline_config() config['output']['misc']['exception-handling'] = 'show-error' - query = text_query("hello") + query = text_query('hello') query.adapter = adapter query.pipeline_config = config @@ -561,6 +578,7 @@ class TestRunnerExceptionFlow: # Create Processor stage from langbot.pkg.pipeline.process import process + processor_stage = process.Processor(pipeline_app) await processor_stage.initialize(query.pipeline_config) @@ -578,14 +596,14 @@ class TestRunnerExceptionFlow: adapter, platform = fake_platform_adapter # Set fake runner that raises exception - fake_runner = FakeRunner().raises(Exception("Hidden error")) + fake_runner = FakeRunner().raises(Exception('Hidden error')) set_fake_runner(fake_runner) # Create query with hide mode config = create_minimal_pipeline_config() config['output']['misc']['exception-handling'] = 'hide' - query = text_query("hello") + query = text_query('hello') query.adapter = adapter query.pipeline_config = config @@ -598,6 +616,7 @@ class TestRunnerExceptionFlow: # Create Processor stage from langbot.pkg.pipeline.process import process + processor_stage = process.Processor(pipeline_app) await processor_stage.initialize(query.pipeline_config) @@ -623,7 +642,7 @@ class TestSendResponseBackStage: adapter, platform = fake_platform_adapter # Create query with response message - query = text_query("hello") + query = text_query('hello') query.adapter = adapter query.pipeline_config = create_minimal_pipeline_config() @@ -643,6 +662,100 @@ class TestSendResponseBackStage: assert len(outbound) == 1 assert outbound[0]['type'] == 'reply' + @pytest.mark.asyncio + async def test_send_response_failure_notifies_plugin_diagnostic(self, pipeline_app): + """Plugin-provided deferred replies should report delivery failures.""" + from langbot.pkg.pipeline import plugin_diagnostics + from langbot.pkg.pipeline.respback import respback + from tests.factories.message import text_chain + from langbot_plugin.api.entities.builtin.provider.message import Message + + query = text_query('hello') + query.adapter.reply_message.side_effect = RuntimeError('send failed') + query.pipeline_config = create_minimal_pipeline_config() + query.current_stage_name = 'SendResponseBackStage' + query.resp_messages = [Message(role='assistant', content='test response')] + query.resp_message_chain = [text_chain('test response')] + plugin_diagnostics.record_plugin_response_source( + query, + 0, + [ + { + 'kind': 'reply_message_chain', + 'plugin': {'author': 'tester', 'name': 'demo'}, + } + ], + [{'manifest': {'metadata': {'author': 'observer', 'name': 'not-reply-source'}}}], + 'NormalMessageResponded', + ) + pipeline_app.plugin_connector.notify_plugin_diagnostic = AsyncMock() + + respback_stage = respback.SendResponseBackStage(pipeline_app) + + with pytest.raises(RuntimeError, match='send failed'): + await respback_stage.process(query, 'SendResponseBackStage') + + pipeline_app.plugin_connector.notify_plugin_diagnostic.assert_awaited_once() + payload = pipeline_app.plugin_connector.notify_plugin_diagnostic.await_args.args[0] + assert payload['code'] == 'response_delivery_failed' + assert payload['plugin'] == {'author': 'tester', 'name': 'demo'} + assert payload['query']['event_name'] == 'NormalMessageResponded' + assert payload['delivery']['error_type'] == 'RuntimeError' + assert 'attribution_warning' not in payload['details'] + + @pytest.mark.asyncio + async def test_send_response_failure_warns_for_old_runtime_attribution(self, pipeline_app): + """Older plugin runtimes without response_sources should get approximate diagnostics.""" + from langbot.pkg.pipeline import plugin_diagnostics + from langbot.pkg.pipeline.respback import respback + from tests.factories.message import text_chain + from langbot_plugin.api.entities.builtin.provider.message import Message + + query = text_query('hello') + query.adapter.reply_message.side_effect = RuntimeError('send failed') + query.pipeline_config = create_minimal_pipeline_config() + query.resp_messages = [Message(role='assistant', content='test response')] + query.resp_message_chain = [text_chain('test response')] + plugin_diagnostics.record_plugin_response_source( + query, + 0, + None, + [{'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}}], + 'NormalMessageResponded', + ) + pipeline_app.plugin_connector.notify_plugin_diagnostic = AsyncMock() + + respback_stage = respback.SendResponseBackStage(pipeline_app) + + with pytest.raises(RuntimeError, match='send failed'): + await respback_stage.process(query, 'SendResponseBackStage') + + payload = pipeline_app.plugin_connector.notify_plugin_diagnostic.await_args.args[0] + assert payload['plugin'] == {'author': 'tester', 'name': 'demo'} + assert 'attribution_warning' in payload['details'] + + @pytest.mark.asyncio + async def test_send_response_failure_ignores_query_variable_spoofing(self, pipeline_app): + """Plugin-controlled query variables must not mask delivery failures.""" + from langbot.pkg.pipeline.respback import respback + from tests.factories.message import text_chain + from langbot_plugin.api.entities.builtin.provider.message import Message + + query = text_query('hello') + query.adapter.reply_message.side_effect = RuntimeError('send failed') + query.pipeline_config = create_minimal_pipeline_config() + query.resp_messages = [Message(role='assistant', content='test response')] + query.resp_message_chain = [text_chain('test response')] + query.variables['_plugin_response_sources'] = {0: ['malformed']} + pipeline_app.plugin_connector.notify_plugin_diagnostic = AsyncMock() + + respback_stage = respback.SendResponseBackStage(pipeline_app) + + with pytest.raises(RuntimeError, match='send failed'): + await respback_stage.process(query, 'SendResponseBackStage') + + pipeline_app.plugin_connector.notify_plugin_diagnostic.assert_not_called() + @pytest.mark.usefixtures('mock_circular_import_chain') class TestStageChainIntegration: @@ -666,12 +779,12 @@ class TestStageChainIntegration: adapter, platform = fake_platform_adapter # Set fake runner - fake_runner = FakeRunner().returns("LANGBOT_FAKE_PONG") + fake_runner = FakeRunner().returns('LANGBOT_FAKE_PONG') set_fake_runner(fake_runner) # Create query config = create_minimal_pipeline_config() - query = text_query("ping") + query = text_query('ping') query.adapter = adapter query.pipeline_config = config query.resp_messages = [] @@ -690,7 +803,7 @@ class TestStageChainIntegration: pipeline_app.plugin_connector.emit_event = AsyncMock() pipeline_app.plugin_connector.emit_event.side_effect = [ - mock_event_ctx_preproc, # PreProcessor PromptPreProcessing + mock_event_ctx_preproc, # PreProcessor PromptPreProcessing mock_event_ctx_processor, # Processor NormalMessageReceived ] @@ -711,6 +824,7 @@ class TestStageChainIntegration: # Build resp_message_chain from resp_messages from tests.factories.message import text_chain + for resp_msg in query.resp_messages: if resp_msg.content: query.resp_message_chain.append(text_chain(resp_msg.content)) @@ -737,7 +851,7 @@ class TestStageChainIntegration: adapter, platform = fake_platform_adapter # Create query - query = text_query("hello") + query = text_query('hello') query.adapter = adapter query.pipeline_config = create_minimal_pipeline_config() @@ -754,7 +868,7 @@ class TestStageChainIntegration: pipeline_app.plugin_connector.emit_event = AsyncMock() pipeline_app.plugin_connector.emit_event.side_effect = [ - mock_event_ctx_preproc, # PreProcessor PromptPreProcessing + mock_event_ctx_preproc, # PreProcessor PromptPreProcessing mock_event_ctx_processor, # Processor NormalMessageReceived ] @@ -775,4 +889,4 @@ class TestStageChainIntegration: assert results[0].result_type == entities.ResultType.INTERRUPT # Chain stops here - no resp_messages - assert len(query.resp_messages) == 0 \ No newline at end of file + assert len(query.resp_messages) == 0 diff --git a/src/langbot/pkg/core/migrations/__init__.py b/tests/integration/vector/__init__.py similarity index 100% rename from src/langbot/pkg/core/migrations/__init__.py rename to tests/integration/vector/__init__.py diff --git a/tests/integration/vector/test_valkey_search.py b/tests/integration/vector/test_valkey_search.py new file mode 100644 index 000000000..64349987b --- /dev/null +++ b/tests/integration/vector/test_valkey_search.py @@ -0,0 +1,343 @@ +"""Integration tests for the Valkey Search VDB backend. + +These are SLOW, real-server tests. They are gated on ``TEST_VALKEY_URL`` and +skipped when it is unset (same precedent as the PostgreSQL migration tests). + +Run locally against valkey/valkey-bundle:9.1.0:: + + podman run -d --name valkey-test-langbot -p 6380:6379 valkey/valkey-bundle:9.1.0 + TEST_VALKEY_URL=valkey://localhost:6380 \\ + uv run pytest tests/integration/vector/test_valkey_search.py -m slow -q + +The default upstream fast CI lane (``-m "not slow"``) skips these; the local +supervisor validator MUST run them. +""" + +from __future__ import annotations + +import asyncio +import os +import uuid +from types import SimpleNamespace +from urllib.parse import urlparse + +import pytest + +pytestmark = [pytest.mark.integration, pytest.mark.slow] + + +def _parse_valkey_url(url: str) -> tuple[str, int, int]: + """Parse ``valkey://host:port/db`` into ``(host, port, db)``.""" + parsed = urlparse(url) + host = parsed.hostname or 'localhost' + port = parsed.port or 6379 + db = 0 + if parsed.path and parsed.path.strip('/'): + try: + db = int(parsed.path.strip('/')) + except ValueError: + db = 0 + return host, port, db + + +@pytest.fixture +def valkey_config(): + url = os.environ.get('TEST_VALKEY_URL') + if not url: + pytest.skip('TEST_VALKEY_URL not set') + host, port, db = _parse_valkey_url(url) + return { + 'host': host, + 'port': port, + 'db': db, + 'password': '', + 'username': '', + 'tls': False, + 'index_algorithm': 'HNSW', + 'distance_metric': 'COSINE', + } + + +def _make_ap(valkey_config): + """Build a minimal fake ``ap`` with the config + a no-op logger.""" + logger = SimpleNamespace( + info=lambda *a, **k: None, + warning=lambda *a, **k: None, + error=lambda *a, **k: None, + debug=lambda *a, **k: None, + ) + instance_config = SimpleNamespace(data={'vdb': {'valkey_search': valkey_config}}) + return SimpleNamespace(instance_config=instance_config, logger=logger) + + +@pytest.fixture +async def backend(valkey_config): + """Create a Valkey Search backend, skip if module/server unavailable.""" + from langbot.pkg.vector.vdbs.valkey_search import ( + ValkeySearchVectorDatabase, + VALKEY_SEARCH_AVAILABLE, + ) + from glide import ft + + if not VALKEY_SEARCH_AVAILABLE: + pytest.skip('valkey-glide not installed') + + ap = _make_ap(valkey_config) + db = ValkeySearchVectorDatabase(ap) + client = await db._ensure_client() + + # Module-presence gate: FT.LIST must be available (Search module loaded). + try: + await ft.list(client) + except Exception as exc: # noqa: BLE001 + await client.close() + pytest.skip(f'Valkey Search module not available: {exc}') + + collection = f'test_{uuid.uuid4().hex[:12]}' + yield db, collection + + # Cleanup + try: + await db.delete_collection(collection) + except Exception: + pass + if db._client is not None: + await db._client.close() + + +async def _poll_until(coro_factory, predicate, timeout=5.0, interval=0.2): + """Poll an async result until predicate is true (indexer is async).""" + deadline = asyncio.get_event_loop().time() + timeout + result = await coro_factory() + while not predicate(result) and asyncio.get_event_loop().time() < deadline: + await asyncio.sleep(interval) + result = await coro_factory() + return result + + +def _sample_docs(): + ids = ['d1', 'd2', 'd3'] + embeddings = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.9, 0.1, 0.0, 0.0], + ] + metadatas = [ + {'file_id': 'fileA', 'topic': 'cats'}, + {'file_id': 'fileB', 'topic': 'dogs'}, + {'file_id': 'fileA', 'topic': 'cats'}, + ] + documents = [ + 'the quick brown fox', + 'lazy dogs sleeping', + 'foxes and cats playing', + ] + return ids, embeddings, metadatas, documents + + +@pytest.mark.asyncio +async def test_add_and_vector_search(backend): + db, collection = backend + ids, embeddings, metadatas, documents = _sample_docs() + await db.add_embeddings(collection, ids, embeddings, metadatas, documents) + + result = await _poll_until( + lambda: db.search(collection, [1.0, 0.0, 0.0, 0.0], k=3, search_type='vector'), + lambda r: len(r['ids'][0]) >= 1, + ) + assert len(result['ids'][0]) >= 1 + # Closest to [1,0,0,0] should be d1. + assert result['ids'][0][0] == 'd1' + assert all(isinstance(d, float) for d in result['distances'][0]) + + +@pytest.mark.asyncio +async def test_full_text_search(backend): + db, collection = backend + ids, embeddings, metadatas, documents = _sample_docs() + await db.add_embeddings(collection, ids, embeddings, metadatas, documents) + + result = await _poll_until( + lambda: db.search(collection, [0.0, 0.0, 0.0, 0.0], k=5, search_type='full_text', query_text='dogs'), + lambda r: len(r['ids'][0]) >= 1, + ) + assert 'd2' in result['ids'][0] + + +@pytest.mark.asyncio +async def test_hybrid_filter_then_knn(backend): + db, collection = backend + ids, embeddings, metadatas, documents = _sample_docs() + await db.add_embeddings(collection, ids, embeddings, metadatas, documents) + + result = await _poll_until( + lambda: db.search( + collection, + [1.0, 0.0, 0.0, 0.0], + k=5, + search_type='hybrid', + query_text='cats', + filter={'file_id': 'fileA'}, + ), + lambda r: len(r['ids'][0]) >= 1, + ) + # Only fileA docs (d1, d3) should be candidates. + assert set(result['ids'][0]).issubset({'d1', 'd3'}) + + +@pytest.mark.asyncio +async def test_vector_weight_not_honored(backend): + """Passing different vector_weight values must NOT change ranking.""" + db, collection = backend + ids, embeddings, metadatas, documents = _sample_docs() + await db.add_embeddings(collection, ids, embeddings, metadatas, documents) + + common = dict( + collection=collection, query_embedding=[1.0, 0.0, 0.0, 0.0], k=3, search_type='hybrid', query_text='cats' + ) + await _poll_until(lambda: db.search(**common), lambda r: len(r['ids'][0]) >= 1) + + r_low = await db.search(**common, vector_weight=0.1) + r_high = await db.search(**common, vector_weight=0.9) + assert r_low['ids'][0] == r_high['ids'][0] + + +@pytest.mark.asyncio +async def test_filter_operators(backend): + db, collection = backend + ids, embeddings, metadatas, documents = _sample_docs() + await db.add_embeddings(collection, ids, embeddings, metadatas, documents) + + # Wait for indexing. + await _poll_until( + lambda: db.list_by_filter(collection, limit=10), + lambda r: r[1] >= 3, + ) + + # $eq + items, total = await db.list_by_filter(collection, filter={'file_id': 'fileA'}) + assert total == 2 + assert {it['id'] for it in items} == {'d1', 'd3'} + + # $ne + items, total = await db.list_by_filter(collection, filter={'file_id': {'$ne': 'fileA'}}) + assert {it['id'] for it in items} == {'d2'} + + # $in + items, total = await db.list_by_filter(collection, filter={'file_id': {'$in': ['fileA', 'fileB']}}) + assert total == 3 + + # $nin + items, total = await db.list_by_filter(collection, filter={'file_id': {'$nin': ['fileB']}}) + assert {it['id'] for it in items} == {'d1', 'd3'} + + +@pytest.mark.asyncio +async def test_delete_by_file_id(backend): + db, collection = backend + ids, embeddings, metadatas, documents = _sample_docs() + await db.add_embeddings(collection, ids, embeddings, metadatas, documents) + await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3) + + await db.delete_by_file_id(collection, 'fileA') + items, total = await _poll_until( + lambda: db.list_by_filter(collection, limit=10), + lambda r: r[1] <= 1, + ) + assert {it['id'] for it in items} == {'d2'} + + +@pytest.mark.asyncio +async def test_delete_by_filter_returns_count(backend): + db, collection = backend + ids, embeddings, metadatas, documents = _sample_docs() + await db.add_embeddings(collection, ids, embeddings, metadatas, documents) + await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3) + + deleted = await db.delete_by_filter(collection, filter={'file_id': 'fileA'}) + assert deleted == 2 + + +@pytest.mark.asyncio +async def test_list_by_filter_pagination(backend): + db, collection = backend + ids, embeddings, metadatas, documents = _sample_docs() + await db.add_embeddings(collection, ids, embeddings, metadatas, documents) + await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3) + + page1, total = await db.list_by_filter(collection, limit=2, offset=0) + assert total == 3 + assert len(page1) == 2 + + page2, total = await db.list_by_filter(collection, limit=2, offset=2) + assert total == 3 + assert len(page2) == 1 + + +@pytest.mark.asyncio +async def test_delete_collection(backend): + db, collection = backend + ids, embeddings, metadatas, documents = _sample_docs() + await db.add_embeddings(collection, ids, embeddings, metadatas, documents) + await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3) + + await db.delete_collection(collection) + + # After dropping, search on a missing index returns empty. + result = await db.search(collection, [1.0, 0.0, 0.0, 0.0], k=3, search_type='vector') + assert result['ids'][0] == [] + + +@pytest.mark.asyncio +async def test_adversarial_filter_and_query_input(backend): + """Crafted FT special chars in file_id / query_text must not break out. + + Guarantees locked in here: + * A file_id full of injection-style chars (quotes, parens, ``|``, ``@``, + ``:``, spaces, dashes) only ever matches its own row — the payload is + escaped to literal TAG content, never interpreted as extra clauses. + * A query_text full of FT operators does not raise and does not widen the + result set. + * A file_id containing FT-unsafe chars (``{`` / ``}`` / ``*``) is + percent-encoded, so it round-trips correctly: an exact match returns ONLY + its own row and never widens to an unrelated row, and the query does not + raise. + """ + db, collection = backend + + # Injection-style file_id WITHOUT FT-unsafe chars (the realistic surface). + injection_fid = 'evil") @file_id (".id|x-y:z' + # file_id WITH FT-unsafe chars that previously could not be queried. + brace_fid = 'x} @file_id:{*' + ids = ['adv1', 'benign2', 'brace3'] + embeddings = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]] + metadatas = [{'file_id': injection_fid}, {'file_id': 'plainB'}, {'file_id': brace_fid}] + documents = ['payload row content', 'unrelated benign content', 'brace row content'] + await db.add_embeddings(collection, ids, embeddings, metadatas, documents) + await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3) + + # Exact-match on the crafted file_id returns ONLY its own row. + items, total = await db.list_by_filter(collection, filter={'file_id': injection_fid}) + assert total == 1 + assert {it['id'] for it in items} == {'adv1'} + + # A query_text packed with FT operators must not raise and must not match + # the benign row (escaped to literal terms, none of which it contains). + result = await db.search( + collection, + [0.0, 0.0, 0.0, 0.0], + k=5, + search_type='full_text', + query_text='@document:{*} | -()~ "evil"', + ) + assert 'benign2' not in result['ids'][0] + + # The brace/star-bearing file_id is encoded, so it round-trips: exact match + # returns ONLY its own row and never widens. No RequestError is raised. + b_items, b_total = await db.list_by_filter(collection, filter={'file_id': brace_fid}) + assert b_total == 1 + assert {it['id'] for it in b_items} == {'brace3'} + + # And deletion by that file_id removes exactly its own row. + deleted = await db.delete_by_filter(collection, filter={'file_id': brace_fid}) + assert deleted == 1 diff --git a/tests/integration_tests/__init__.py b/tests/integration_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration_tests/box/__init__.py b/tests/integration_tests/box/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration_tests/box/test_box_integration.py b/tests/integration_tests/box/test_box_integration.py new file mode 100644 index 000000000..c20a1d87f --- /dev/null +++ b/tests/integration_tests/box/test_box_integration.py @@ -0,0 +1,329 @@ +"""Integration tests for LangBot Box. + +These tests verify the end-to-end behavior of the Box sandbox execution +system. Tests decorated with ``requires_container`` need a real container +runtime (Podman or Docker) and are skipped otherwise. + +CI only runs ``tests/unit_tests/``, so these tests never execute in the +CI pipeline. Run them locally with:: + + pytest tests/integration_tests/ -v +""" + +from __future__ import annotations + +import asyncio +import logging +import shutil +import socket +import subprocess +from types import SimpleNamespace + +import pytest + +from langbot.pkg.box.service import BoxService +from langbot_plugin.box.backend import BaseSandboxBackend +from langbot_plugin.box.client import ActionRPCBoxClient +from langbot_plugin.box.errors import BoxBackendUnavailableError +from langbot_plugin.box.models import BoxExecutionStatus, BoxNetworkMode, BoxSpec +from langbot_plugin.box.runtime import BoxRuntime +from langbot_plugin.box.server import BoxServerHandler + +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + +_logger = logging.getLogger('test.box.integration') + +# Default image for integration tests — small and fast to pull. +_TEST_IMAGE = 'alpine:latest' + + +# ── Skip helpers ────────────────────────────────────────────────────── + + +def _has_container_runtime() -> bool: + for cmd in ('podman', 'docker'): + if shutil.which(cmd) is None: + continue + try: + result = subprocess.run( + [cmd, 'info'], + capture_output=True, + timeout=10, + ) + if result.returncode == 0: + return True + except Exception: + continue + return False + + +def _can_open_test_socket() -> bool: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + except OSError: + return False + sock.close() + return True + + +requires_container = pytest.mark.skipif( + not _has_container_runtime(), + reason='no container runtime (podman/docker) available', +) + +requires_socket = pytest.mark.skipif( + not _can_open_test_socket(), + reason='local test environment does not permit opening TCP sockets', +) + + +# ── Helpers ────────────────────────────────────────────────────────── + + +class _QueueConnection: + """In-process Connection backed by asyncio Queues — no real IO.""" + + def __init__(self, rx: asyncio.Queue[str], tx: asyncio.Queue[str]): + self._rx = rx + self._tx = tx + + async def send(self, message: str) -> None: + await self._tx.put(message) + + async def receive(self) -> str: + return await self._rx.get() + + async def close(self) -> None: + pass + + +async def _make_rpc_pair(runtime: BoxRuntime): + """Create an in-process (ActionRPCBoxClient, server_task, client_task) connected via queues.""" + from langbot_plugin.runtime.io.handler import Handler + + c2s: asyncio.Queue[str] = asyncio.Queue() + s2c: asyncio.Queue[str] = asyncio.Queue() + client_conn = _QueueConnection(rx=s2c, tx=c2s) + server_conn = _QueueConnection(rx=c2s, tx=s2c) + + server_handler = BoxServerHandler(server_conn, runtime) + server_task = asyncio.create_task(server_handler.run()) + + client_handler = Handler.__new__(Handler) + Handler.__init__(client_handler, client_conn) + client_task = asyncio.create_task(client_handler.run()) + + client = ActionRPCBoxClient(logger=_logger) + client.set_handler(client_handler) + + return client, server_task, client_task + + +# ── Fixtures ────────────────────────────────────────────────────────── + + +@pytest.fixture +async def box_client(): + """Yield an ActionRPCBoxClient backed by a real BoxRuntime via in-process RPC.""" + runtime = BoxRuntime(logger=_logger) + await runtime.initialize() + client, server_task, client_task = await _make_rpc_pair(runtime) + yield client + server_task.cancel() + client_task.cancel() + await runtime.shutdown() + + +# ── 1. Simple command execution ─────────────────────────────────────── + + +@requires_container +@requires_socket +@pytest.mark.asyncio +async def test_exec_simple_command(box_client: ActionRPCBoxClient): + """Box starts a simple command and returns stdout.""" + spec = BoxSpec( + cmd='echo hello-box', + session_id='int-simple', + workdir='/tmp', + image=_TEST_IMAGE, + ) + result = await box_client.execute(spec) + + assert result.status == BoxExecutionStatus.COMPLETED + assert result.exit_code == 0 + assert 'hello-box' in result.stdout + + +# ── 2. Session file persistence ─────────────────────────────────────── + + +@requires_container +@requires_socket +@pytest.mark.asyncio +async def test_session_persists_files(box_client: ActionRPCBoxClient): + """Write a file in one exec, read it back in a second exec on the same session.""" + sid = 'int-persist' + + write_result = await box_client.execute( + BoxSpec( + cmd='echo "hello from file" > /tmp/testfile.txt', + session_id=sid, + workdir='/tmp', + image=_TEST_IMAGE, + ) + ) + assert write_result.exit_code == 0 + + read_result = await box_client.execute( + BoxSpec( + cmd='cat /tmp/testfile.txt', + session_id=sid, + workdir='/tmp', + image=_TEST_IMAGE, + ) + ) + assert read_result.exit_code == 0 + assert 'hello from file' in read_result.stdout + + +# ── 3. Timeout handling ─────────────────────────────────────────────── + + +@requires_container +@requires_socket +@pytest.mark.asyncio +async def test_timeout_kills_command(box_client: ActionRPCBoxClient): + """A long-running command is killed after timeout_sec.""" + session_id = 'int-timeout' + spec = BoxSpec( + cmd='sleep 120', + session_id=session_id, + workdir='/tmp', + timeout_sec=3, + image=_TEST_IMAGE, + ) + result = await box_client.execute(spec) + + assert result.status == BoxExecutionStatus.TIMED_OUT + assert result.exit_code is None + + sessions = await box_client.get_sessions() + assert all(session['session_id'] != session_id for session in sessions) + + +# ── 4. Network isolation ───────────────────────────────────────────── + + +@requires_container +@requires_socket +@pytest.mark.asyncio +async def test_offline_cannot_reach_network(box_client: ActionRPCBoxClient): + """With network=OFF the sandbox cannot reach the internet.""" + spec = BoxSpec( + cmd='wget -q -O /dev/null --timeout=3 http://1.1.1.1 2>&1; exit $?', + session_id='int-offline', + workdir='/tmp', + network=BoxNetworkMode.OFF, + image=_TEST_IMAGE, + ) + result = await box_client.execute(spec) + + assert result.exit_code != 0 + + +# ── 5. Backend unavailable ─────────────────────────────────────────── + + +class _UnavailableBackend(BaseSandboxBackend): + """A backend that always reports itself as unavailable.""" + + name = 'unavailable' + + def __init__(self): + super().__init__(logging.getLogger('test')) + + async def is_available(self) -> bool: + return False + + async def start_session(self, spec): + raise NotImplementedError + + async def exec(self, session, spec): + raise NotImplementedError + + async def stop_session(self, session): + pass + + +@requires_socket +@pytest.mark.asyncio +async def test_backend_unavailable_returns_error(): + """When no backend is available the full RPC path returns BoxBackendUnavailableError.""" + runtime = BoxRuntime(logger=_logger, backends=[_UnavailableBackend()]) + await runtime.initialize() + client, server_task, client_task = await _make_rpc_pair(runtime) + try: + spec = BoxSpec( + cmd='echo hello', + session_id='int-no-backend', + workdir='/tmp', + ) + with pytest.raises(BoxBackendUnavailableError): + await client.execute(spec) + finally: + server_task.cancel() + client_task.cancel() + await runtime.shutdown() + + +# ── 6. Full service-to-runtime path ────────────────────────────────── + + +@requires_container +@requires_socket +@pytest.mark.asyncio +async def test_full_service_to_remote_runtime(tmp_path): + """BoxService -> ActionRPCBoxClient -> RPC -> BoxRuntime -> real backend.""" + runtime = BoxRuntime(logger=_logger) + await runtime.initialize() + client, server_task, client_task = await _make_rpc_pair(runtime) + try: + host_dir = tmp_path / 'workspace' + host_dir.mkdir() + + mock_ap = SimpleNamespace( + logger=_logger, + instance_config=SimpleNamespace( + data={ + 'box': { + 'backend': 'local', + 'runtime': {'endpoint': ''}, + 'local': { + 'profile': 'default', + 'allowed_mount_roots': [str(tmp_path)], + 'default_workspace': str(host_dir), + }, + 'e2b': {'api_key': '', 'api_url': '', 'template': ''}, + } + } + ), + ) + + service = BoxService(mock_ap, client=client) + await service.initialize() + + query = pipeline_query.Query.model_construct(query_id=42) + result = await service.execute_tool( + {'command': 'echo service-path'}, + query, + ) + + assert result['ok'] is True + assert result['status'] == 'completed' + assert 'service-path' in result['stdout'] + assert result['session_id'] == 'query_42' + finally: + server_task.cancel() + client_task.cancel() + await runtime.shutdown() diff --git a/tests/integration_tests/box/test_box_mcp_integration.py b/tests/integration_tests/box/test_box_mcp_integration.py new file mode 100644 index 000000000..2fcfcb934 --- /dev/null +++ b/tests/integration_tests/box/test_box_mcp_integration.py @@ -0,0 +1,368 @@ +"""Integration tests for Box MCP-related features. + +These tests verify managed process lifecycle, WebSocket stdio attach, +session cleanup, and the single-session query API using a real container +runtime. + +CI only runs ``tests/unit_tests/``, so these tests never execute in the +CI pipeline. Run them locally with:: + + pytest tests/integration_tests/box/test_box_mcp_integration.py -v +""" + +from __future__ import annotations + +import asyncio +import logging +import shutil +import socket +import subprocess + +import aiohttp +import pytest +from aiohttp.test_utils import TestServer + +from langbot_plugin.box.client import ActionRPCBoxClient +from langbot_plugin.box.errors import BoxManagedProcessNotFoundError, BoxSessionNotFoundError +from langbot_plugin.box.models import BoxManagedProcessSpec, BoxManagedProcessStatus, BoxSpec +from langbot_plugin.box.runtime import BoxRuntime +from langbot_plugin.box.server import BoxServerHandler, create_ws_relay_app + +_logger = logging.getLogger('test.box.mcp_integration') + +_TEST_IMAGE = 'alpine:latest' + + +# ── Skip helpers ────────────────────────────────────────────────────── + + +def _has_container_runtime() -> bool: + for cmd in ('podman', 'docker'): + if shutil.which(cmd) is None: + continue + try: + result = subprocess.run([cmd, 'info'], capture_output=True, timeout=10) + if result.returncode == 0: + return True + except Exception: + continue + return False + + +def _can_open_test_socket() -> bool: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + except OSError: + return False + sock.close() + return True + + +requires_container = pytest.mark.skipif( + not _has_container_runtime(), + reason='no container runtime (podman/docker) available', +) + +requires_socket = pytest.mark.skipif( + not _can_open_test_socket(), + reason='local test environment does not permit opening TCP sockets', +) + + +# ── Helpers ────────────────────────────────────────────────────────── + + +class _QueueConnection: + """In-process Connection backed by asyncio Queues — no real IO.""" + + def __init__(self, rx: asyncio.Queue[str], tx: asyncio.Queue[str]): + self._rx = rx + self._tx = tx + + async def send(self, message: str) -> None: + await self._tx.put(message) + + async def receive(self) -> str: + return await self._rx.get() + + async def close(self) -> None: + pass + + +async def _make_rpc_pair(runtime: BoxRuntime): + """Create an in-process RPC pair connected via queues.""" + from langbot_plugin.runtime.io.handler import Handler + + c2s: asyncio.Queue[str] = asyncio.Queue() + s2c: asyncio.Queue[str] = asyncio.Queue() + client_conn = _QueueConnection(rx=s2c, tx=c2s) + server_conn = _QueueConnection(rx=c2s, tx=s2c) + + server_handler = BoxServerHandler(server_conn, runtime) + server_task = asyncio.create_task(server_handler.run()) + + client_handler = Handler.__new__(Handler) + Handler.__init__(client_handler, client_conn) + client_task = asyncio.create_task(client_handler.run()) + + client = ActionRPCBoxClient(logger=_logger) + client.set_handler(client_handler) + + return client, server_task, client_task + + +# ── Fixtures ────────────────────────────────────────────────────────── + + +@pytest.fixture +async def box_server(): + """Yield a (ws_relay_url, ActionRPCBoxClient) backed by a real BoxRuntime.""" + runtime = BoxRuntime(logger=_logger) + await runtime.initialize() + + # Start ws relay for managed process attach + ws_app = create_ws_relay_app(runtime) + ws_server = TestServer(ws_app) + await ws_server.start_server() + + client, server_task, client_task = await _make_rpc_pair(runtime) + + ws_relay_url = str(ws_server.make_url('')) + yield ws_relay_url, client + + server_task.cancel() + client_task.cancel() + await runtime.shutdown() + await ws_server.close() + + +# ── 1. Managed process lifecycle ───────────────────────────────────── + + +@requires_container +@requires_socket +@pytest.mark.asyncio +async def test_managed_process_start_and_query(box_server): + """Start a managed process and query its status.""" + ws_relay_url, client = box_server + + # Create session + spec = BoxSpec( + cmd='', + session_id='mcp-int-lifecycle', + workdir='/tmp', + image=_TEST_IMAGE, + ) + await client.create_session(spec) + + # Start a managed process that stays alive + proc_spec = BoxManagedProcessSpec( + command='sh', + args=['-c', 'while true; do sleep 1; done'], + cwd='/tmp', + ) + info = await client.start_managed_process('mcp-int-lifecycle', proc_spec) + assert info.status == BoxManagedProcessStatus.RUNNING + + # Query it + info2 = await client.get_managed_process('mcp-int-lifecycle') + assert info2.status == BoxManagedProcessStatus.RUNNING + assert info2.command == 'sh' + + # Stop only the managed process while keeping the session available + await client.stop_managed_process('mcp-int-lifecycle') + with pytest.raises(BoxManagedProcessNotFoundError): + await client.get_managed_process('mcp-int-lifecycle') + session_info = await client.get_session('mcp-int-lifecycle') + assert session_info['session_id'] == 'mcp-int-lifecycle' + + # Cleanup + await client.delete_session('mcp-int-lifecycle') + + +# ── 2. WebSocket stdio attach ──────────────────────────────────────── + + +@requires_container +@requires_socket +@pytest.mark.asyncio +async def test_ws_stdio_attach_echo(box_server): + """Attach to a managed process via WebSocket and verify bidirectional IO.""" + ws_relay_url, client = box_server + + spec = BoxSpec( + cmd='', + session_id='mcp-int-ws', + workdir='/tmp', + image=_TEST_IMAGE, + ) + await client.create_session(spec) + + # Start a cat process (echoes stdin to stdout) + proc_spec = BoxManagedProcessSpec( + command='cat', + args=[], + cwd='/tmp', + ) + await client.start_managed_process('mcp-int-ws', proc_spec) + + # Connect via WebSocket (ws relay) + ws_url = client.get_managed_process_websocket_url('mcp-int-ws', ws_relay_url) + session = aiohttp.ClientSession() + try: + async with session.ws_connect(ws_url) as ws: + # Send a line + await ws.send_str('hello from test') + + # Expect to receive it back (cat echoes) + msg = await asyncio.wait_for(ws.receive(), timeout=5) + assert msg.type == aiohttp.WSMsgType.TEXT + assert 'hello from test' in msg.data + finally: + await session.close() + + await client.delete_session('mcp-int-ws') + + +# ── 3. Session cleanup removes container ───────────────────────────── + + +@requires_container +@requires_socket +@pytest.mark.asyncio +async def test_delete_session_cleans_up(box_server): + """After deleting a session, it should no longer exist.""" + ws_relay_url, client = box_server + + spec = BoxSpec( + cmd='', + session_id='mcp-int-cleanup', + workdir='/tmp', + image=_TEST_IMAGE, + ) + await client.create_session(spec) + + # Start a process + proc_spec = BoxManagedProcessSpec( + command='sleep', + args=['3600'], + cwd='/tmp', + ) + await client.start_managed_process('mcp-int-cleanup', proc_spec) + + # Delete + await client.delete_session('mcp-int-cleanup') + + # Session should be gone + with pytest.raises(BoxSessionNotFoundError): + await client.get_session('mcp-int-cleanup') + + +# ── 4. GET session details ──────────────────────────────────────── + + +@requires_container +@requires_socket +@pytest.mark.asyncio +async def test_get_session_returns_details(box_server): + """Get single session returns session details and managed process info.""" + ws_relay_url, client = box_server + + spec = BoxSpec( + cmd='', + session_id='mcp-int-get', + workdir='/tmp', + image=_TEST_IMAGE, + ) + await client.create_session(spec) + + # Query without managed process + info = await client.get_session('mcp-int-get') + assert info['session_id'] == 'mcp-int-get' + assert info['image'] == _TEST_IMAGE + assert 'managed_process' not in info + + # Start a process and query again + proc_spec = BoxManagedProcessSpec( + command='sleep', + args=['3600'], + cwd='/tmp', + ) + await client.start_managed_process('mcp-int-get', proc_spec) + + info2 = await client.get_session('mcp-int-get') + assert info2['session_id'] == 'mcp-int-get' + assert 'managed_process' in info2 + assert info2['managed_process']['status'] == BoxManagedProcessStatus.RUNNING.value + + await client.delete_session('mcp-int-get') + + +# ── 5. Process exit detected ──────────────────────────────────────── + + +@requires_container +@requires_socket +@pytest.mark.asyncio +async def test_process_exit_detected(box_server): + """When a managed process exits, its status should reflect EXITED.""" + ws_relay_url, client = box_server + + spec = BoxSpec( + cmd='', + session_id='mcp-int-exit', + workdir='/tmp', + image=_TEST_IMAGE, + ) + await client.create_session(spec) + + # Start a process that exits immediately + proc_spec = BoxManagedProcessSpec( + command='sh', + args=['-c', 'echo done && exit 0'], + cwd='/tmp', + ) + await client.start_managed_process('mcp-int-exit', proc_spec) + + # Wait a bit for process to exit + await asyncio.sleep(2) + + info = await client.get_managed_process('mcp-int-exit') + assert info.status == BoxManagedProcessStatus.EXITED + assert info.exit_code == 0 + + await client.delete_session('mcp-int-exit') + + +# ── 6. Instance ID orphan cleanup ─────────────────────────────────── + + +@requires_container +@requires_socket +@pytest.mark.asyncio +async def test_orphan_cleanup_preserves_own_containers(box_server): + """Orphan cleanup should not remove containers belonging to the current instance.""" + ws_relay_url, client = box_server + + # Create a session (container gets current instance ID label) + spec = BoxSpec( + cmd='', + session_id='mcp-int-orphan', + workdir='/tmp', + image=_TEST_IMAGE, + ) + await client.create_session(spec) + + # Verify session exists + sessions = await client.get_sessions() + assert any(s['session_id'] == 'mcp-int-orphan' for s in sessions) + + # Trigger status check (which doesn't clean up own containers) + status = await client.get_status() + assert status['active_sessions'] >= 1 + + # Our session should still exist + sessions = await client.get_sessions() + assert any(s['session_id'] == 'mcp-int-orphan' for s in sessions) + + await client.delete_session('mcp-int-orphan') diff --git a/tests/manual/mcp_smoke.py b/tests/manual/mcp_smoke.py new file mode 100644 index 000000000..197724fff --- /dev/null +++ b/tests/manual/mcp_smoke.py @@ -0,0 +1,132 @@ +"""End-to-end test: boot the real MCPMount on a port and drive it with an MCP client. + +Exercises the ASGI dispatcher (auth + /mcp routing), the FastMCP streamable-HTTP +transport, and a real tool call against the (mocked) service layer. + +Run: uv run --no-sync python tests/manual/mcp_smoke.py +""" + +from __future__ import annotations + +import asyncio +import contextlib +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from hypercorn.asyncio import serve +from hypercorn.config import Config +from quart import Quart + +from langbot.pkg.api.mcp.mount import MCPMount + +PORT = 5399 +GLOBAL_KEY = 'test-global-key-123' + + +def build_ap() -> SimpleNamespace: + ap = SimpleNamespace() + ap.instance_config = SimpleNamespace( + data={'api': {'global_api_key': GLOBAL_KEY}, 'system': {'edition': 'community', 'instance_id': 'inst-1'}} + ) + ap.ver_mgr = SimpleNamespace(get_current_version=lambda: '4.5.0-test') + ap.logger = SimpleNamespace(info=print, error=print, warning=print) + + # API key verification: reuse real logic shape (global key match) + async def verify_api_key(key: str) -> bool: + return bool(key) and key == GLOBAL_KEY + + ap.apikey_service = SimpleNamespace(verify_api_key=verify_api_key) + ap.bot_service = SimpleNamespace( + get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}]) + ) + ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'pl-1', 'name': 'default'}])) + ap.llm_model_service = SimpleNamespace(get_llm_models=AsyncMock(return_value=[])) + ap.embedding_models_service = SimpleNamespace(get_embedding_models=AsyncMock(return_value=[])) + ap.provider_service = SimpleNamespace(get_providers=AsyncMock(return_value=[])) + ap.knowledge_service = SimpleNamespace(get_knowledge_bases=AsyncMock(return_value=[])) + ap.mcp_service = SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[])) + ap.skill_service = SimpleNamespace(list_skills=AsyncMock(return_value=[{'name': 'demo-skill'}])) + return ap + + +async def run_server(mount: MCPMount, shutdown: asyncio.Event) -> None: + quart_app = Quart(__name__) + + @quart_app.route('/healthz') + async def healthz(): + return {'code': 0, 'msg': 'ok'} + + config = Config() + config.bind = [f'127.0.0.1:{PORT}'] + config.accesslog = None + asgi = mount.wrap(quart_app) + await serve(asgi, config, shutdown_trigger=shutdown.wait) + + +async def main() -> int: + from mcp.client.session import ClientSession + from mcp.client.streamable_http import streamablehttp_client + + ap = build_ap() + mount = MCPMount(ap) + await mount.start_session_manager() + + shutdown = asyncio.Event() + server_task = asyncio.create_task(run_server(mount, shutdown)) + await asyncio.sleep(1.0) # let the server bind + + url = f'http://127.0.0.1:{PORT}/mcp' + failures = [] + + # 1. Unauthorized request is rejected. + import httpx + + async with httpx.AsyncClient() as client: + r = await client.post(url, json={'jsonrpc': '2.0', 'id': 1, 'method': 'ping'}) + if r.status_code != 401: + failures.append(f'expected 401 without key, got {r.status_code}') + else: + print('PASS: unauthorized request rejected (401)') + + # 2. Authorized MCP session: list tools + call two. + headers = {'X-API-Key': GLOBAL_KEY} + async with streamablehttp_client(url, headers=headers) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + tools = await session.list_tools() + names = [t.name for t in tools.tools] + print(f'PASS: listed {len(names)} tools') + for required in ('list_bots', 'get_system_info', 'list_skills'): + if required not in names: + failures.append(f'missing tool {required}') + + res = await session.call_tool('list_bots', {}) + text = res.content[0].text if res.content else '' + if 'Demo Bot' not in text: + failures.append(f'list_bots did not return expected data: {text!r}') + else: + print('PASS: list_bots returned bot data') + + res2 = await session.call_tool('get_system_info', {}) + text2 = res2.content[0].text if res2.content else '' + if '4.5.0-test' not in text2: + failures.append(f'get_system_info wrong: {text2!r}') + else: + print('PASS: get_system_info returned version') + + shutdown.set() + with contextlib.suppress(Exception): + await asyncio.wait_for(server_task, timeout=5) + await mount.stop_session_manager() + + if failures: + print('\nFAILURES:') + for f in failures: + print(' -', f) + return 1 + print('\nALL MCP SMOKE CHECKS PASSED') + return 0 + + +if __name__ == '__main__': + raise SystemExit(asyncio.run(main())) diff --git a/tests/smoke/__init__.py b/tests/smoke/__init__.py index 5f7e6721b..a4634f28c 100644 --- a/tests/smoke/__init__.py +++ b/tests/smoke/__init__.py @@ -3,4 +3,4 @@ Smoke tests package. Smoke tests verify basic functionality works without testing edge cases. Run with: uv run pytest tests/smoke/ -q -""" \ No newline at end of file +""" diff --git a/tests/smoke/test_fake_message_flow.py b/tests/smoke/test_fake_message_flow.py index aa1bf827d..5ae195f2f 100644 --- a/tests/smoke/test_fake_message_flow.py +++ b/tests/smoke/test_fake_message_flow.py @@ -39,19 +39,19 @@ class TestFakeMessageFlow: assert app.instance_config is not None # Verify default config - assert app.instance_config.data["command"]["prefix"] == ["/", "!"] - assert app.instance_config.data["command"]["enable"] is True + assert app.instance_config.data['command']['prefix'] == ['/', '!'] + assert app.instance_config.data['command']['enable'] is True @pytest.mark.asyncio async def test_fake_provider_returns_text(self): """Test FakeProvider returns configured response.""" - provider = FakeProvider(default_response="test response") + provider = FakeProvider(default_response='test response') # Create mock model with provider model = fake_model(provider=provider) # Create a simple query - query = text_query("hello") + query = text_query('hello') # Simulate invoke result = await provider.invoke_llm( @@ -63,15 +63,15 @@ class TestFakeMessageFlow: ) assert result is not None - assert result.role == "assistant" - assert result.content == "test response" + assert result.role == 'assistant' + assert result.content == 'test response' @pytest.mark.asyncio async def test_fake_provider_pong(self): """Test FakeProvider returns LANGBOT_FAKE_PONG marker.""" provider = fake_provider_pong() model = fake_model(provider=provider) - query = text_query("ping") + query = text_query('ping') result = await provider.invoke_llm( query=query, @@ -86,9 +86,9 @@ class TestFakeMessageFlow: @pytest.mark.asyncio async def test_fake_provider_streaming(self): """Test FakeProvider streaming response.""" - provider = FakeProvider().returns_streaming(["Hello", " World"]) + provider = FakeProvider().returns_streaming(['Hello', ' World']) model = fake_model(provider=provider) - query = text_query("hello") + query = text_query('hello') chunks = [] # invoke_llm_stream returns an async generator, don't await it @@ -102,8 +102,8 @@ class TestFakeMessageFlow: chunks.append(chunk) assert len(chunks) == 2 - assert chunks[0].content == "Hello" - assert chunks[1].content == " World" + assert chunks[0].content == 'Hello' + assert chunks[1].content == ' World' assert chunks[1].is_final is True @pytest.mark.asyncio @@ -111,9 +111,9 @@ class TestFakeMessageFlow: """Test FakeProvider simulates timeout error.""" provider = FakeProvider().timeout() model = fake_model(provider=provider) - query = text_query("hello") + query = text_query('hello') - with pytest.raises(TimeoutError, match="Provider timeout"): + with pytest.raises(TimeoutError, match='Provider timeout'): await provider.invoke_llm( query=query, model=model, @@ -127,9 +127,9 @@ class TestFakeMessageFlow: """Test FakeProvider simulates rate limit error.""" provider = FakeProvider().rate_limit() model = fake_model(provider=provider) - query = text_query("hello") + query = text_query('hello') - with pytest.raises(Exception, match="Rate limit exceeded"): + with pytest.raises(Exception, match='Rate limit exceeded'): await provider.invoke_llm( query=query, model=model, @@ -142,34 +142,34 @@ class TestFakeMessageFlow: async def test_fake_provider_captures_requests(self): """Test FakeProvider captures request arguments.""" provider = FakeProvider() - model = fake_model(name="gpt-4", provider=provider) - query = text_query("hello") + model = fake_model(name='gpt-4', provider=provider) + query = text_query('hello') await provider.invoke_llm( query=query, model=model, - messages=[{"role": "user", "content": "hello"}], - funcs=[{"name": "test_func"}], - extra_args={"temperature": 0.7}, + messages=[{'role': 'user', 'content': 'hello'}], + funcs=[{'name': 'test_func'}], + extra_args={'temperature': 0.7}, ) captured = provider.get_captured_requests() assert len(captured) == 1 - assert captured[0]["model"] == "gpt-4" - assert captured[0]["messages"] == [{"role": "user", "content": "hello"}] - assert captured[0]["funcs"] == [{"name": "test_func"}] - assert captured[0]["extra_args"] == {"temperature": 0.7} + assert captured[0]['model'] == 'gpt-4' + assert captured[0]['messages'] == [{'role': 'user', 'content': 'hello'}] + assert captured[0]['funcs'] == [{'name': 'test_func'}] + assert captured[0]['extra_args'] == {'temperature': 0.7} @pytest.mark.asyncio async def test_fake_platform_capture_outbound(self): """Test FakePlatform captures outbound messages.""" - platform = FakePlatform(bot_account_id="test-bot") - query = text_query("hello") + platform = FakePlatform(bot_account_id='test-bot') + query = text_query('hello') # Simulate sending reply from tests.factories.message import text_chain - reply_chain = text_chain("response text") + reply_chain = text_chain('response text') event = query.message_event await platform.reply_message(event, reply_chain, quote_origin=False) @@ -177,38 +177,38 @@ class TestFakeMessageFlow: # Verify captured outbound = platform.get_outbound_messages() assert len(outbound) == 1 - assert outbound[0]["type"] == "reply" - assert outbound[0]["message"] == reply_chain + assert outbound[0]['type'] == 'reply' + assert outbound[0]['message'] == reply_chain @pytest.mark.asyncio async def test_fake_platform_friend_message(self): """Test FakePlatform creates friend message events.""" - platform = FakePlatform(bot_account_id="test-bot") + platform = FakePlatform(bot_account_id='test-bot') event = platform.create_friend_message( - text="hello bot", + text='hello bot', sender_id=12345, - nickname="TestUser", + nickname='TestUser', ) - assert event.type == "FriendMessage" + assert event.type == 'FriendMessage' assert event.sender.id == 12345 - assert event.sender.nickname == "TestUser" - assert str(event.message_chain) == "hello bot" + assert event.sender.nickname == 'TestUser' + assert str(event.message_chain) == 'hello bot' @pytest.mark.asyncio async def test_fake_platform_group_message_with_mention(self): """Test FakePlatform creates group message with @mention.""" - platform = FakePlatform(bot_account_id="test-bot") + platform = FakePlatform(bot_account_id='test-bot') event = platform.create_group_message( - text="hello everyone", + text='hello everyone', sender_id=12345, group_id=99999, mention_bot=True, ) - assert event.type == "GroupMessage" + assert event.type == 'GroupMessage' assert event.sender.id == 12345 assert event.group.id == 99999 @@ -220,54 +220,57 @@ class TestFakeMessageFlow: async def test_query_factories_basic(self): """Test basic query factory functions.""" # Text query - q1 = text_query("hello world") - assert q1.launcher_type.value == "person" - assert str(q1.message_chain) == "hello world" + q1 = text_query('hello world') + assert q1.launcher_type.value == 'person' + assert str(q1.message_chain) == 'hello world' # Group query from tests.factories import group_text_query - q2 = group_text_query("hello group", group_id=88888) - assert q2.launcher_type.value == "group" + + q2 = group_text_query('hello group', group_id=88888) + assert q2.launcher_type.value == 'group' assert q2.launcher_id == 88888 # Command query from tests.factories import command_query - q3 = command_query("help", prefix="/") - assert str(q3.message_chain) == "/help" + + q3 = command_query('help', prefix='/') + assert str(q3.message_chain) == '/help' # Mention query from tests.factories import mention_query - q4 = mention_query("hi", target="test-bot", group_id=77777) - assert q4.launcher_type.value == "group" + + q4 = mention_query('hi', target='test-bot', group_id=77777) + assert q4.launcher_type.value == 'group' @pytest.mark.asyncio async def test_fake_platform_send_failure(self): """Test FakePlatform simulates send failure.""" platform = FakePlatform().send_failure() - query = text_query("hello") + query = text_query('hello') from tests.factories.message import text_chain - with pytest.raises(Exception, match="Platform send failure"): + with pytest.raises(Exception, match='Platform send failure'): await platform.reply_message( query.message_event, - text_chain("response"), + text_chain('response'), ) @pytest.mark.asyncio async def test_mock_platform_adapter(self): """Test mock_platform_adapter helper.""" - platform = FakePlatform(bot_account_id="bot-123") + platform = FakePlatform(bot_account_id='bot-123') adapter = mock_platform_adapter(platform) - assert adapter.bot_account_id == "bot-123" + assert adapter.bot_account_id == 'bot-123' assert adapter._fake_platform is platform # Test reply_message is wired from tests.factories.message import text_chain - query = text_query("test") - await adapter.reply_message(query.message_event, text_chain("response")) + query = text_query('test') + await adapter.reply_message(query.message_event, text_chain('response')) # Verify platform captured it assert len(platform.get_outbound_messages()) == 1 @@ -293,18 +296,18 @@ class TestMessageFlowIntegration: Note: This does NOT run actual LangBot pipeline stages. """ # Setup - platform = FakePlatform(bot_account_id="test-bot") + platform = FakePlatform(bot_account_id='test-bot') provider = fake_provider_pong() model = fake_model(provider=provider) # Create inbound message - query = text_query("ping") + query = text_query('ping') # Simulate provider processing response = await provider.invoke_llm( query=query, model=model, - messages=[{"role": "user", "content": "ping"}], + messages=[{'role': 'user', 'content': 'ping'}], funcs=[], extra_args={}, ) @@ -321,16 +324,16 @@ class TestMessageFlowIntegration: # Verify platform captured outbound outbound = platform.get_outbound_messages() assert len(outbound) == 1 - assert outbound[0]["type"] == "reply" - assert str(outbound[0]["message"]) == FakeProvider.PONG_RESPONSE + assert outbound[0]['type'] == 'reply' + assert str(outbound[0]['message']) == FakeProvider.PONG_RESPONSE @pytest.mark.asyncio async def test_streaming_message_flow(self): """Smoke test: streaming message flow.""" platform = FakePlatform().supports_streaming() - provider = FakeProvider().returns_streaming(["Hello", " there"]) + provider = FakeProvider().returns_streaming(['Hello', ' there']) model = fake_model(provider=provider) - query = text_query("hi") + query = text_query('hi') chunks = [] async for chunk in provider.invoke_llm_stream( @@ -344,8 +347,8 @@ class TestMessageFlowIntegration: # Verify streaming worked assert len(chunks) == 2 - full_content = "".join(c.content for c in chunks) - assert full_content == "Hello there" + full_content = ''.join(c.content for c in chunks) + assert full_content == 'Hello there' # Verify platform supports streaming - assert await platform.is_stream_output_supported() is True \ No newline at end of file + assert await platform.is_stream_output_supported() is True diff --git a/tests/test_cwe94_debug_exec.py b/tests/test_cwe94_debug_exec.py index 48e08d1a2..b31085981 100644 --- a/tests/test_cwe94_debug_exec.py +++ b/tests/test_cwe94_debug_exec.py @@ -15,22 +15,12 @@ import pathlib # Resolve project root (one level up from tests/) _PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent -VULN_FILE = ( - _PROJECT_ROOT - / "src" - / "langbot" - / "pkg" - / "api" - / "http" - / "controller" - / "groups" - / "system.py" -) +VULN_FILE = _PROJECT_ROOT / 'src' / 'langbot' / 'pkg' / 'api' / 'http' / 'controller' / 'groups' / 'system.py' def test_no_exec_call_in_system_controller(): """Verify there is no exec() call in system.py that takes user input.""" - with open(VULN_FILE, "r") as f: + with open(VULN_FILE, 'r') as f: source = f.read() tree = ast.parse(source) @@ -40,27 +30,26 @@ def test_no_exec_call_in_system_controller(): if isinstance(node, ast.Call): func = node.func # Match bare exec() call - if isinstance(func, ast.Name) and func.id == "exec": + if isinstance(func, ast.Name) and func.id == 'exec': exec_calls.append(node.lineno) assert len(exec_calls) == 0, ( - f"Found exec() call(s) at line(s) {exec_calls} in system.py. " - "User-supplied code must never be passed to exec()." + f'Found exec() call(s) at line(s) {exec_calls} in system.py. User-supplied code must never be passed to exec().' ) def test_no_debug_exec_route(): """Verify the /debug/exec route is not registered.""" - with open(VULN_FILE, "r") as f: + with open(VULN_FILE, 'r') as f: source = f.read() - assert "debug/exec" not in source, ( - "The /debug/exec route still exists in system.py. " - "This endpoint allows arbitrary code execution and must be removed." + assert 'debug/exec' not in source, ( + 'The /debug/exec route still exists in system.py. ' + 'This endpoint allows arbitrary code execution and must be removed.' ) -if __name__ == "__main__": +if __name__ == '__main__': test_no_exec_call_in_system_controller() test_no_debug_exec_route() - print("All tests passed!") + print('All tests passed!') diff --git a/tests/unit_tests/COVERAGE_EXCLUSIONS.md b/tests/unit_tests/COVERAGE_EXCLUSIONS.md index 1e3f28cec..f0e161158 100644 --- a/tests/unit_tests/COVERAGE_EXCLUSIONS.md +++ b/tests/unit_tests/COVERAGE_EXCLUSIONS.md @@ -27,7 +27,7 @@ ### 4. 向量数据库 (`vector/vdbs/`) - **路径**: `src/langbot/pkg/vector/vdbs/` -- **模块**: chroma, milvus, pgvector, qdrant, seekdb +- **模块**: chroma, milvus, pgvector, qdrant, seekdb, valkey_search - **排除原因**: 需要真实向量数据库实例运行 - **测试方式**: 需要 Docker 启动测试数据库或 mock - **状态**: 后续可补充 mock 测试 diff --git a/tests/unit_tests/api/__init__.py b/tests/unit_tests/api/__init__.py index d8628d82d..42c4689ce 100644 --- a/tests/unit_tests/api/__init__.py +++ b/tests/unit_tests/api/__init__.py @@ -1 +1 @@ -"""Unit tests for LangBot API HTTP service layer.""" \ No newline at end of file +"""Unit tests for LangBot API HTTP service layer.""" diff --git a/tests/unit_tests/api/service/__init__.py b/tests/unit_tests/api/service/__init__.py index 67828f4d8..7d53c4c5f 100644 --- a/tests/unit_tests/api/service/__init__.py +++ b/tests/unit_tests/api/service/__init__.py @@ -13,4 +13,4 @@ Does NOT: - Call real provider/platform/network Uses tests.factories.FakeApp as base mock application. -""" \ No newline at end of file +""" diff --git a/tests/unit_tests/api/service/test_apikey_service.py b/tests/unit_tests/api/service/test_apikey_service.py index e71879874..9726888eb 100644 --- a/tests/unit_tests/api/service/test_apikey_service.py +++ b/tests/unit_tests/api/service/test_apikey_service.py @@ -132,9 +132,7 @@ class TestApiKeyServiceCreateApiKey: with patch('langbot.pkg.api.http.service.apikey.secrets.token_urlsafe', return_value='fixed-token'): result = await service.create_api_key('New Key', 'Test description') - assert insert_params == [ - {'name': 'New Key', 'key': 'lbk_fixed-token', 'description': 'Test description'} - ] + assert insert_params == [{'name': 'New Key', 'key': 'lbk_fixed-token', 'description': 'Test description'}] assert result['key'].startswith('lbk_') assert result['key'] == 'lbk_fixed-token' assert result['name'] == 'New Key' @@ -257,16 +255,22 @@ class TestApiKeyServiceGetApiKey: class TestApiKeyServiceVerifyApiKey: """Tests for verify_api_key method.""" + @staticmethod + def _make_ap(db_key=None, global_api_key=''): + """Build a mock Application with persistence + instance_config.""" + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace() + mock_result = Mock() + mock_result.first = Mock(return_value=db_key) + ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) + ap.instance_config = SimpleNamespace(data={'api': {'global_api_key': global_api_key}}) + return ap + async def test_verify_api_key_valid(self): """Returns True for valid API key.""" # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - key = Mock(spec=ApiKey) - mock_result = Mock() - mock_result.first = Mock(return_value=key) - ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) + ap = self._make_ap(db_key=key) service = ApiKeyService(ap) @@ -279,12 +283,7 @@ class TestApiKeyServiceVerifyApiKey: async def test_verify_api_key_invalid(self): """Returns False for invalid API key.""" # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - - mock_result = Mock() - mock_result.first = Mock(return_value=None) - ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) + ap = self._make_ap(db_key=None) service = ApiKeyService(ap) @@ -297,12 +296,7 @@ class TestApiKeyServiceVerifyApiKey: async def test_verify_api_key_empty_string(self): """Returns False for empty key string.""" # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - - mock_result = Mock() - mock_result.first = Mock(return_value=None) - ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) + ap = self._make_ap(db_key=None) service = ApiKeyService(ap) @@ -315,12 +309,7 @@ class TestApiKeyServiceVerifyApiKey: async def test_verify_api_key_unknown_key(self): """Returns False when the key is not present in persistence.""" # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - - mock_result = Mock() - mock_result.first = Mock(return_value=None) - ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) + ap = self._make_ap(db_key=None) service = ApiKeyService(ap) @@ -330,6 +319,70 @@ class TestApiKeyServiceVerifyApiKey: # Verify assert result is False + async def test_verify_global_api_key_match(self): + """Returns True when key matches the config.yaml global API key (no DB lookup).""" + # Setup: no DB record, but a global key is configured + ap = self._make_ap(db_key=None, global_api_key='my-global-secret') + + service = ApiKeyService(ap) + + # Execute + result = await service.verify_api_key('my-global-secret') + + # Verify: accepted purely on config match + assert result is True + # DB should not have been consulted for the global-key path + ap.persistence_mgr.execute_async.assert_not_called() + + async def test_verify_global_api_key_no_prefix_required(self): + """Global API key is accepted even without the lbk_ prefix.""" + ap = self._make_ap(db_key=None, global_api_key='plainsecret123') + + service = ApiKeyService(ap) + + result = await service.verify_api_key('plainsecret123') + + assert result is True + + async def test_verify_global_api_key_mismatch_falls_back_to_db(self): + """A non-matching key still falls through to the DB lookup.""" + # Global key set, but request uses a different lbk_ key that IS in DB + key = Mock(spec=ApiKey) + ap = self._make_ap(db_key=key, global_api_key='my-global-secret') + + service = ApiKeyService(ap) + + result = await service.verify_api_key('lbk_db_key') + + assert result is True + ap.persistence_mgr.execute_async.assert_called_once() + + async def test_verify_empty_global_api_key_disabled(self): + """An empty global_api_key must never authenticate an empty/blank request.""" + ap = self._make_ap(db_key=None, global_api_key='') + + service = ApiKeyService(ap) + + # Empty request key is rejected, and a blank global key never matches + assert await service.verify_api_key('') is False + assert await service.verify_api_key(' ') is False + + async def test_verify_api_key_missing_global_config_key(self): + """Works even when api.global_api_key is absent (existing installs).""" + # instance_config without the global_api_key field at all + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace() + mock_result = Mock() + mock_result.first = Mock(return_value=None) + ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) + ap.instance_config = SimpleNamespace(data={'api': {}}) + + service = ApiKeyService(ap) + + result = await service.verify_api_key('lbk_some_key') + + assert result is False + class TestApiKeyServiceDeleteApiKey: """Tests for delete_api_key method.""" diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py index c1e5abfe6..8a6d0ad2a 100644 --- a/tests/unit_tests/api/service/test_bot_service.py +++ b/tests/unit_tests/api/service/test_bot_service.py @@ -303,13 +303,7 @@ class TestBotServiceCreateBot: ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() ap.instance_config = SimpleNamespace() - ap.instance_config.data = { - 'system': { - 'limitation': { - 'max_bots': 2 - } - } - } + ap.instance_config.data = {'system': {'limitation': {'max_bots': 2}}} ap.platform_mgr = SimpleNamespace() ap.platform_mgr.load_bot = AsyncMock() @@ -318,9 +312,7 @@ class TestBotServiceCreateBot: bot2 = _create_mock_bot(bot_uuid='uuid-2') mock_result = _create_mock_result([bot1, bot2]) ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) - ap.persistence_mgr.serialize_model = Mock( - return_value={'uuid': 'uuid-1', 'name': 'Bot 1'} - ) + ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'uuid-1', 'name': 'Bot 1'}) service = BotService(ap) @@ -352,6 +344,7 @@ class TestBotServiceCreateBot: bot_result.first = Mock(return_value=_create_mock_bot()) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -362,9 +355,7 @@ class TestBotServiceCreateBot: return bot_result # Get bot ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) - ap.persistence_mgr.serialize_model = Mock( - return_value={'uuid': 'new-uuid', 'name': 'New Bot'} - ) + ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid', 'name': 'New Bot'}) service = BotService(ap) @@ -397,6 +388,7 @@ class TestBotServiceCreateBot: bot_result.first = Mock(return_value=_create_mock_bot()) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -492,6 +484,7 @@ class TestBotServiceUpdateBot: pipeline_result.first = Mock(return_value=mock_pipeline) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -582,10 +575,9 @@ class TestBotServiceListEventLogs: # Mock runtime bot with logger runtime_bot = SimpleNamespace() runtime_bot.logger = SimpleNamespace() - runtime_bot.logger.get_logs = AsyncMock(return_value=( - [SimpleNamespace(to_json=Mock(return_value={'msg': 'log1'}))], - 5 - )) + runtime_bot.logger.get_logs = AsyncMock( + return_value=([SimpleNamespace(to_json=Mock(return_value={'msg': 'log1'}))], 5) + ) ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot) service = BotService(ap) @@ -646,11 +638,7 @@ class TestBotServiceSendMessage: service = BotService(ap) # Execute with valid message chain format - message_chain_data = { - 'messages': [ - {'type': 'text', 'data': {'text': 'Hello'}} - ] - } + message_chain_data = {'messages': [{'type': 'text', 'data': {'text': 'Hello'}}]} # Patch the import location - the module imports inside the function with patch('langbot_plugin.api.entities.builtin.platform.message.MessageChain') as MockMessageChain: diff --git a/tests/unit_tests/api/service/test_knowledge_service.py b/tests/unit_tests/api/service/test_knowledge_service.py index 87aeddcff..1e0592b01 100644 --- a/tests/unit_tests/api/service/test_knowledge_service.py +++ b/tests/unit_tests/api/service/test_knowledge_service.py @@ -6,6 +6,7 @@ Tests cover: - Knowledge engine discovery - File operations """ + from __future__ import annotations import pytest @@ -52,9 +53,7 @@ class TestGetKnowledgeBases: """Test that it returns all knowledge base details.""" knowledge_module = get_knowledge_service_module() mock_app = create_mock_app() - mock_app.rag_mgr.get_all_knowledge_base_details = AsyncMock( - return_value=[{'uuid': 'kb1', 'name': 'KB1'}] - ) + mock_app.rag_mgr.get_all_knowledge_base_details = AsyncMock(return_value=[{'uuid': 'kb1', 'name': 'KB1'}]) service = knowledge_module.KnowledgeService(mock_app) result = await service.get_knowledge_bases() @@ -83,9 +82,7 @@ class TestGetKnowledgeBase: """Test that it returns specific KB details.""" knowledge_module = get_knowledge_service_module() mock_app = create_mock_app() - mock_app.rag_mgr.get_knowledge_base_details = AsyncMock( - return_value={'uuid': 'kb1', 'name': 'KB1'} - ) + mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(return_value={'uuid': 'kb1', 'name': 'KB1'}) service = knowledge_module.KnowledgeService(mock_app) result = await service.get_knowledge_base('kb1') @@ -153,9 +150,7 @@ class TestCreateKnowledgeBase: service = knowledge_module.KnowledgeService(mock_app) - await service.create_knowledge_base({ - 'knowledge_engine_plugin_id': 'author/engine' - }) + await service.create_knowledge_base({'knowledge_engine_plugin_id': 'author/engine'}) # Check that default name 'Untitled' was used call_args = mock_app.rag_mgr.create_knowledge_base.call_args @@ -170,20 +165,21 @@ class TestUpdateKnowledgeBase: """Test that only mutable fields are updated.""" knowledge_module = get_knowledge_service_module() mock_app = create_mock_app() - mock_app.rag_mgr.get_knowledge_base_details = AsyncMock( - return_value={'uuid': 'kb1', 'name': 'Updated'} - ) + mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(return_value={'uuid': 'kb1', 'name': 'Updated'}) mock_app.rag_mgr.remove_knowledge_base_from_runtime = AsyncMock() mock_app.rag_mgr.load_knowledge_base = AsyncMock() service = knowledge_module.KnowledgeService(mock_app) # Pass both mutable and immutable fields - await service.update_knowledge_base('kb1', { - 'name': 'New Name', - 'description': 'New desc', - 'uuid': 'should_be_filtered', # immutable - }) + await service.update_knowledge_base( + 'kb1', + { + 'name': 'New Name', + 'description': 'New desc', + 'uuid': 'should_be_filtered', # immutable + }, + ) # Check that only mutable fields were passed to update call_args = mock_app.persistence_mgr.execute_async.call_args @@ -288,9 +284,7 @@ class TestListKnowledgeEngines: """Test that it returns empty list and logs warning on exception.""" knowledge_module = get_knowledge_service_module() mock_app = create_mock_app() - mock_app.plugin_connector.list_knowledge_engines = AsyncMock( - side_effect=Exception('Connection error') - ) + mock_app.plugin_connector.list_knowledge_engines = AsyncMock(side_effect=Exception('Connection error')) service = knowledge_module.KnowledgeService(mock_app) result = await service.list_knowledge_engines() @@ -386,12 +380,10 @@ class TestGetEngineSchemas: """Test that it returns empty dict and logs warning on exception.""" knowledge_module = get_knowledge_service_module() mock_app = create_mock_app() - mock_app.plugin_connector.get_rag_creation_schema = AsyncMock( - side_effect=Exception('Plugin error') - ) + mock_app.plugin_connector.get_rag_creation_schema = AsyncMock(side_effect=Exception('Plugin error')) service = knowledge_module.KnowledgeService(mock_app) result = await service.get_engine_creation_schema('author/engine') assert result == {} - mock_app.logger.warning.assert_called_once() \ No newline at end of file + mock_app.logger.warning.assert_called_once() diff --git a/tests/unit_tests/api/service/test_maintenance_service.py b/tests/unit_tests/api/service/test_maintenance_service.py index fcedf8b4e..8d5b5b0df 100644 --- a/tests/unit_tests/api/service/test_maintenance_service.py +++ b/tests/unit_tests/api/service/test_maintenance_service.py @@ -174,9 +174,7 @@ class TestMaintenanceServiceGetStorageAnalysis: # Setup ap = SimpleNamespace() ap.instance_config = SimpleNamespace() - ap.instance_config.data = { - 'database': {'use': 'sqlite', 'sqlite': {'path': 'data/langbot.db'}} - } + ap.instance_config.data = {'database': {'use': 'sqlite', 'sqlite': {'path': 'data/langbot.db'}}} ap.persistence_mgr = SimpleNamespace() ap.logger = SimpleNamespace() ap.logger.warning = Mock() @@ -292,12 +290,8 @@ class TestMaintenanceServiceGetStorageAnalysis: service._file_count = Mock(return_value=0) service._monitoring_counts = AsyncMock(return_value={}) service._binary_storage_stats = AsyncMock(return_value={'count': 0, 'size_bytes': 0}) - service._expired_uploaded_candidates = AsyncMock(return_value=[ - {'key': 'old_file', 'size_bytes': 100} - ]) - service._expired_log_candidates = Mock(return_value=[ - {'name': 'old_log', 'size_bytes': 50} - ]) + service._expired_uploaded_candidates = AsyncMock(return_value=[{'key': 'old_file', 'size_bytes': 100}]) + service._expired_log_candidates = Mock(return_value=[{'name': 'old_log', 'size_bytes': 50}]) # Execute result = await service.get_storage_analysis() @@ -367,6 +361,7 @@ class TestMaintenanceServiceBinaryStorageStats: size_result = _create_mock_result(scalar_value=5000) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -396,6 +391,7 @@ class TestMaintenanceServiceBinaryStorageStats: count_result = _create_mock_result(scalar_value=5) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -821,4 +817,4 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates: result = service._expired_local_upload_candidates(7, include_paths=True) # Verify - path included - assert 'path' in result[0] \ No newline at end of file + assert 'path' in result[0] diff --git a/tests/unit_tests/api/service/test_mcp_service.py b/tests/unit_tests/api/service/test_mcp_service.py index 7f6ae83c6..ea08897a1 100644 --- a/tests/unit_tests/api/service/test_mcp_service.py +++ b/tests/unit_tests/api/service/test_mcp_service.py @@ -90,6 +90,56 @@ class TestMCPServiceGetRuntimeInfo: assert result is None +class TestMCPServiceResources: + """Tests for MCP resource helpers.""" + + async def test_get_resource_templates_delegates_to_loader(self): + ap = SimpleNamespace() + ap.tool_mgr = SimpleNamespace() + ap.tool_mgr.mcp_tool_loader = SimpleNamespace() + ap.tool_mgr.mcp_tool_loader.get_resource_templates = AsyncMock( + return_value=[{'uri_template': 'file:///{path}', 'name': 'files'}] + ) + + service = MCPService(ap) + + result = await service.get_mcp_server_resource_templates('docs') + + assert result == [{'uri_template': 'file:///{path}', 'name': 'files'}] + ap.tool_mgr.mcp_tool_loader.get_resource_templates.assert_awaited_once_with('docs') + + async def test_read_resource_envelope_uses_ui_preview_source(self): + ap = SimpleNamespace() + ap.tool_mgr = SimpleNamespace() + ap.tool_mgr.mcp_tool_loader = SimpleNamespace() + ap.tool_mgr.mcp_tool_loader.read_resource_envelope = AsyncMock( + return_value={ + 'server_name': 'docs', + 'uri': 'file:///README.md', + 'contents': [], + 'source': 'ui_preview', + } + ) + + service = MCPService(ap) + + result = await service.read_mcp_server_resource_envelope( + 'docs', + 'file:///README.md', + max_bytes=4096, + include_blob=True, + ) + + assert result['source'] == 'ui_preview' + ap.tool_mgr.mcp_tool_loader.read_resource_envelope.assert_awaited_once_with( + 'docs', + 'file:///README.md', + include_blob=True, + source='ui_preview', + max_bytes=4096, + ) + + class TestMCPServiceGetMCPServers: """Tests for get_mcp_servers method.""" @@ -186,13 +236,7 @@ class TestMCPServiceCreateMCPServer: ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() ap.instance_config = SimpleNamespace() - ap.instance_config.data = { - 'system': { - 'limitation': { - 'max_extensions': 2 - } - } - } + ap.instance_config.data = {'system': {'limitation': {'max_extensions': 2}}} ap.plugin_connector = SimpleNamespace() ap.plugin_connector.list_plugins = AsyncMock(return_value=[Mock(), Mock()]) # 2 plugins @@ -236,6 +280,25 @@ class TestMCPServiceCreateMCPServer: assert server_uuid is not None assert len(server_uuid) == 36 # UUID format + async def test_create_mcp_server_duplicate_name_raises(self): + """Rejects duplicate MCP server names.""" + # Setup + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace() + ap.instance_config = SimpleNamespace() + ap.instance_config.data = {'system': {'limitation': {'max_extensions': -1}}} + ap.tool_mgr = None + + existing_server = _create_mock_mcp_server(name='Existing Server') + ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=existing_server)) + ap.persistence_mgr.serialize_model = Mock(return_value={}) + + service = MCPService(ap) + + # Execute & Verify + with pytest.raises(ValueError, match='MCP server already exists: Existing Server'): + await service.create_mcp_server({'name': 'Existing Server'}) + async def test_create_mcp_server_loads_server(self): """Loads server into tool_mgr when enabled.""" # Setup @@ -252,11 +315,12 @@ class TestMCPServiceCreateMCPServer: server_entity = _create_mock_mcp_server(server_uuid='new-uuid', enable=True) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 if call_count == 1: - return _create_mock_result([]) # Empty list for limit check + return _create_mock_result([]) # Empty result for duplicate-name check elif call_count == 2: return Mock() # Insert return _create_mock_result(first_item=server_entity) # Select created @@ -361,6 +425,7 @@ class TestMCPServiceUpdateMCPServer: old_server = _create_mock_mcp_server(name='Old Server', enable=True) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -394,6 +459,7 @@ class TestMCPServiceUpdateMCPServer: updated_server = _create_mock_mcp_server(name='Old Server', enable=True) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -432,6 +498,7 @@ class TestMCPServiceUpdateMCPServer: # Mock for: first select -> update -> second select (for updated server) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -465,6 +532,7 @@ class TestMCPServiceUpdateMCPServer: # Mock execute for select and update call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -499,6 +567,7 @@ class TestMCPServiceDeleteMCPServer: server = _create_mock_mcp_server(name='Server to Delete') call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -530,6 +599,7 @@ class TestMCPServiceDeleteMCPServer: server = _create_mock_mcp_server(name='Not in Sessions') call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -559,6 +629,7 @@ class TestMCPServiceDeleteMCPServer: # No server found call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -596,9 +667,7 @@ class TestMCPServiceTestMCPServer: ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=mock_session) ap.task_mgr = SimpleNamespace() - ap.task_mgr.create_user_task = Mock( - return_value=SimpleNamespace(id=123) - ) + ap.task_mgr.create_user_task = Mock(return_value=SimpleNamespace(id=123)) service = MCPService(ap) @@ -634,9 +703,7 @@ class TestMCPServiceTestMCPServer: ap.tool_mgr.mcp_tool_loader.load_mcp_server = AsyncMock(return_value=mock_session) ap.task_mgr = SimpleNamespace() - ap.task_mgr.create_user_task = Mock( - return_value=SimpleNamespace(id=456) - ) + ap.task_mgr.create_user_task = Mock(return_value=SimpleNamespace(id=456)) service = MCPService(ap) @@ -645,4 +712,4 @@ class TestMCPServiceTestMCPServer: # Verify - load_mcp_server called ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once() - assert task_id == 456 \ No newline at end of file + assert task_id == 456 diff --git a/tests/unit_tests/api/service/test_model_service.py b/tests/unit_tests/api/service/test_model_service.py index 6e6d2598d..42129ed3b 100644 --- a/tests/unit_tests/api/service/test_model_service.py +++ b/tests/unit_tests/api/service/test_model_service.py @@ -23,6 +23,7 @@ from langbot.pkg.api.http.service.model import ( RerankModelsService, _parse_provider_api_keys, _runtime_model_data, + _validate_provider_supports, ) from langbot.pkg.entity.persistence.model import LLMModel, EmbeddingModel, RerankModel, ModelProvider @@ -35,6 +36,7 @@ def _create_mock_llm_model( name: str = 'Test LLM', provider_uuid: str = 'provider-uuid', abilities: list = None, + context_length: int | None = None, extra_args: dict = None, ) -> Mock: """Helper to create mock LLMModel entity.""" @@ -43,6 +45,7 @@ def _create_mock_llm_model( model.name = name model.provider_uuid = provider_uuid model.abilities = abilities or [] + model.context_length = context_length model.extra_args = extra_args or {} return model @@ -142,10 +145,12 @@ class TestRuntimeModelData: 'name': 'Model', 'provider_uuid': 'provider', 'abilities': ['vision'], + 'context_length': 128000, 'extra_args': {'temp': 0.7}, } result = _runtime_model_data('uuid', update_payload) assert result['abilities'] == ['vision'] + assert result['context_length'] == 128000 assert result['extra_args'] == {'temp': 0.7} @@ -162,6 +167,7 @@ class TestLLMModelsServiceGetLLMModels: mock_provider_result = _create_mock_result([]) call_count = 0 + async def mock_execute(query): return mock_result if call_count == 0 else mock_provider_result @@ -188,13 +194,14 @@ class TestLLMModelsServiceGetLLMModels: ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() - model = _create_mock_llm_model() + model = _create_mock_llm_model(context_length=128000) provider = _create_mock_provider() mock_model_result = _create_mock_result([model]) mock_provider_result = _create_mock_result([provider]) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -206,6 +213,7 @@ class TestLLMModelsServiceGetLLMModels: 'uuid': entity.uuid, 'name': entity.name, 'provider_uuid': entity.provider_uuid if hasattr(entity, 'provider_uuid') else None, + 'context_length': getattr(entity, 'context_length', None), 'api_keys': entity.api_keys if hasattr(entity, 'api_keys') else None, } ) @@ -218,6 +226,7 @@ class TestLLMModelsServiceGetLLMModels: # Verify assert len(result) == 1 assert result[0]['name'] == 'Test LLM' + assert result[0]['context_length'] == 128000 async def test_get_llm_models_hide_secret_keys(self): """Hides secret API keys when include_secret=False.""" @@ -232,6 +241,7 @@ class TestLLMModelsServiceGetLLMModels: mock_provider_result = _create_mock_result([provider]) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -265,13 +275,14 @@ class TestLLMModelsServiceGetLLMModel: ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() - model = _create_mock_llm_model(model_uuid='found-uuid') + model = _create_mock_llm_model(model_uuid='found-uuid', context_length=128000) provider = _create_mock_provider() mock_model_result = _create_mock_result([], first_item=model) mock_provider_result = _create_mock_result([], first_item=provider) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -279,11 +290,12 @@ class TestLLMModelsServiceGetLLMModel: ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) ap.persistence_mgr.serialize_model = Mock( - return_value={ - 'uuid': 'found-uuid', - 'name': 'Test LLM', - 'provider_uuid': 'provider-uuid', - 'provider': {'uuid': 'provider-uuid', 'api_keys': ['key']}, + side_effect=lambda model_cls, entity: { + 'uuid': entity.uuid, + 'name': entity.name, + 'provider_uuid': getattr(entity, 'provider_uuid', None), + 'context_length': getattr(entity, 'context_length', None), + 'api_keys': getattr(entity, 'api_keys', None), } ) @@ -295,6 +307,7 @@ class TestLLMModelsServiceGetLLMModel: # Verify assert result is not None assert result['uuid'] == 'found-uuid' + assert result['context_length'] == 128000 async def test_get_llm_model_not_found(self): """Returns None when model not found.""" @@ -328,9 +341,7 @@ class TestLLMModelsServiceGetLLMModelsByProvider: mock_result = _create_mock_result([model1, model2]) ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) - ap.persistence_mgr.serialize_model = Mock( - return_value={'uuid': 'model-1', 'name': 'Model 1'} - ) + ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'model-1', 'name': 'Model 1'}) service = LLMModelsService(ap) @@ -362,12 +373,14 @@ class TestLLMModelsServiceCreateLLMModel: service = LLMModelsService(ap) # Execute - model_uuid = await service.create_llm_model({ - 'name': 'New LLM', - 'provider_uuid': 'provider-uuid', - 'abilities': [], - 'extra_args': {}, - }) + model_uuid = await service.create_llm_model( + { + 'name': 'New LLM', + 'provider_uuid': 'provider-uuid', + 'abilities': [], + 'extra_args': {}, + } + ) # Verify assert model_uuid is not None @@ -391,17 +404,53 @@ class TestLLMModelsServiceCreateLLMModel: service = LLMModelsService(ap) # Execute - model_uuid = await service.create_llm_model({ - 'uuid': 'preserved-uuid', - 'name': 'Preserved UUID Model', - 'provider_uuid': 'provider-uuid', - 'abilities': [], - 'extra_args': {}, - }, preserve_uuid=True) + model_uuid = await service.create_llm_model( + { + 'uuid': 'preserved-uuid', + 'name': 'Preserved UUID Model', + 'provider_uuid': 'provider-uuid', + 'abilities': [], + 'extra_args': {}, + }, + preserve_uuid=True, + ) # Verify assert model_uuid == 'preserved-uuid' + async def test_create_llm_model_persists_context_length_as_column(self): + """Creates LLM model with context_length outside extra_args.""" + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace() + ap.model_mgr = SimpleNamespace() + ap.model_mgr.provider_dict = {'provider-uuid': Mock()} + ap.model_mgr.llm_models = [] + ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock()) + ap.pipeline_service = SimpleNamespace(update_pipeline=AsyncMock()) + + mock_result = _create_mock_result([]) + ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) + + service = LLMModelsService(ap) + + await service.create_llm_model( + { + 'uuid': 'model-with-context', + 'name': 'Context Model', + 'provider_uuid': 'provider-uuid', + 'abilities': ['func_call'], + 'context_length': 128000, + 'extra_args': {'temperature': 0.2}, + }, + preserve_uuid=True, + auto_set_to_default_pipeline=False, + ) + + runtime_entity = ap.model_mgr.load_llm_model_with_provider.await_args.args[0] + assert runtime_entity.context_length == 128000 + assert runtime_entity.extra_args == {'temperature': 0.2} + assert 'context_length' not in runtime_entity.extra_args + async def test_create_llm_model_provider_not_found_raises_error(self): """Raises Exception when provider not found in runtime.""" # Setup @@ -417,12 +466,14 @@ class TestLLMModelsServiceCreateLLMModel: # Execute & Verify with pytest.raises(Exception, match='provider not found'): - await service.create_llm_model({ - 'name': 'No Provider Model', - 'provider_uuid': 'nonexistent-provider', - 'abilities': [], - 'extra_args': {}, - }) + await service.create_llm_model( + { + 'name': 'No Provider Model', + 'provider_uuid': 'nonexistent-provider', + 'abilities': [], + 'extra_args': {}, + } + ) async def test_create_llm_model_with_provider_data(self): """Creates provider when provider data provided.""" @@ -448,16 +499,18 @@ class TestLLMModelsServiceCreateLLMModel: service = LLMModelsService(ap) # Execute - with provider data (no UUID) - result_uuid = await service.create_llm_model({ - 'name': 'Model with New Provider', - 'provider': { - 'requester': 'openai', - 'base_url': 'https://api.openai.com', - 'api_keys': ['key'], - }, - 'abilities': [], - 'extra_args': {}, - }) + result_uuid = await service.create_llm_model( + { + 'name': 'Model with New Provider', + 'provider': { + 'requester': 'openai', + 'base_url': 'https://api.openai.com', + 'api_keys': ['key'], + }, + 'abilities': [], + 'extra_args': {}, + } + ) # Verify - provider_service was called and UUID generated ap.provider_service.find_or_create_provider.assert_called_once() @@ -483,11 +536,14 @@ class TestLLMModelsServiceUpdateLLMModel: service = LLMModelsService(ap) # Execute - await service.update_llm_model('existing-uuid', { - 'uuid': 'should-be-removed', - 'name': 'Updated Name', - 'provider_uuid': 'provider-uuid', - }) + await service.update_llm_model( + 'existing-uuid', + { + 'uuid': 'should-be-removed', + 'name': 'Updated Name', + 'provider_uuid': 'provider-uuid', + }, + ) # Verify - remove and load called ap.model_mgr.remove_llm_model.assert_called_once_with('existing-uuid') @@ -507,10 +563,42 @@ class TestLLMModelsServiceUpdateLLMModel: # Execute & Verify with pytest.raises(Exception, match='provider not found'): - await service.update_llm_model('model-uuid', { - 'name': 'Update', - 'provider_uuid': 'nonexistent-provider', - }) + await service.update_llm_model( + 'model-uuid', + { + 'name': 'Update', + 'provider_uuid': 'nonexistent-provider', + }, + ) + + async def test_update_llm_model_reloads_context_length_as_column(self): + """Updates runtime model with context_length outside extra_args.""" + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock()) + ap.model_mgr = SimpleNamespace() + ap.model_mgr.provider_dict = {'provider-uuid': Mock()} + ap.model_mgr.llm_models = [] + ap.model_mgr.remove_llm_model = AsyncMock() + ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock()) + + service = LLMModelsService(ap) + + await service.update_llm_model( + 'existing-uuid', + { + 'name': 'Updated Name', + 'provider_uuid': 'provider-uuid', + 'abilities': ['vision'], + 'context_length': 64000, + 'extra_args': {'temperature': 0.4}, + }, + ) + + runtime_entity = ap.model_mgr.load_llm_model_with_provider.await_args.args[0] + assert runtime_entity.uuid == 'existing-uuid' + assert runtime_entity.context_length == 64000 + assert runtime_entity.extra_args == {'temperature': 0.4} + assert 'context_length' not in runtime_entity.extra_args class TestLLMModelsServiceDeleteLLMModel: @@ -547,9 +635,7 @@ class TestEmbeddingModelsServiceGetEmbeddingModels: mock_result = _create_mock_result([]) ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) - ap.persistence_mgr.serialize_model = Mock( - return_value={'uuid': 'embedding-uuid', 'name': 'Test'} - ) + ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'embedding-uuid', 'name': 'Test'}) service = EmbeddingModelsService(ap) @@ -572,6 +658,7 @@ class TestEmbeddingModelsServiceGetEmbeddingModels: mock_provider_result = _create_mock_result([provider]) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -612,6 +699,7 @@ class TestEmbeddingModelsServiceGetEmbeddingModel: mock_provider_result = _create_mock_result([], first_item=provider) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -671,11 +759,13 @@ class TestEmbeddingModelsServiceCreateEmbeddingModel: service = EmbeddingModelsService(ap) # Execute - model_uuid = await service.create_embedding_model({ - 'name': 'New Embedding', - 'provider_uuid': 'provider-uuid', - 'extra_args': {}, - }) + model_uuid = await service.create_embedding_model( + { + 'name': 'New Embedding', + 'provider_uuid': 'provider-uuid', + 'extra_args': {}, + } + ) # Verify assert model_uuid is not None @@ -696,11 +786,13 @@ class TestEmbeddingModelsServiceCreateEmbeddingModel: # Execute & Verify with pytest.raises(Exception, match='provider not found'): - await service.create_embedding_model({ - 'name': 'No Provider Embedding', - 'provider_uuid': 'nonexistent', - 'extra_args': {}, - }) + await service.create_embedding_model( + { + 'name': 'No Provider Embedding', + 'provider_uuid': 'nonexistent', + 'extra_args': {}, + } + ) class TestEmbeddingModelsServiceDeleteEmbeddingModel: @@ -758,6 +850,7 @@ class TestRerankModelsServiceGetRerankModels: mock_provider_result = _create_mock_result([provider]) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -798,6 +891,7 @@ class TestRerankModelsServiceGetRerankModel: mock_provider_result = _create_mock_result([], first_item=provider) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -857,11 +951,13 @@ class TestRerankModelsServiceCreateRerankModel: service = RerankModelsService(ap) # Execute - model_uuid = await service.create_rerank_model({ - 'name': 'New Rerank', - 'provider_uuid': 'provider-uuid', - 'extra_args': {}, - }) + model_uuid = await service.create_rerank_model( + { + 'name': 'New Rerank', + 'provider_uuid': 'provider-uuid', + 'extra_args': {}, + } + ) # Verify assert model_uuid is not None @@ -881,11 +977,13 @@ class TestRerankModelsServiceCreateRerankModel: # Execute & Verify with pytest.raises(Exception, match='provider not found'): - await service.create_rerank_model({ - 'name': 'No Provider Rerank', - 'provider_uuid': 'nonexistent', - 'extra_args': {}, - }) + await service.create_rerank_model( + { + 'name': 'No Provider Rerank', + 'provider_uuid': 'nonexistent', + 'extra_args': {}, + } + ) class TestRerankModelsServiceDeleteRerankModel: @@ -924,9 +1022,7 @@ class TestEmbeddingModelsServiceGetEmbeddingModelsByProvider: mock_result = _create_mock_result([model1, model2]) ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) - ap.persistence_mgr.serialize_model = Mock( - return_value={'uuid': 'emb-1', 'name': 'Embedding 1'} - ) + ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'emb-1', 'name': 'Embedding 1'}) service = EmbeddingModelsService(ap) @@ -951,9 +1047,7 @@ class TestRerankModelsServiceGetRerankModelsByProvider: mock_result = _create_mock_result([model1, model2]) ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) - ap.persistence_mgr.serialize_model = Mock( - return_value={'uuid': 'rerank-1', 'name': 'Rerank 1'} - ) + ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'rerank-1', 'name': 'Rerank 1'}) service = RerankModelsService(ap) @@ -961,4 +1055,50 @@ class TestRerankModelsServiceGetRerankModelsByProvider: result = await service.get_rerank_models_by_provider('provider-uuid') # Verify - assert len(result) == 2 \ No newline at end of file + assert len(result) == 2 + + +class TestValidateProviderSupports: + """Tests for _validate_provider_supports guard.""" + + @staticmethod + def _make_ap(requester_name: str, support_type): + """Build a fake ap whose model_mgr resolves a manifest with support_type.""" + manifest = SimpleNamespace(spec={'support_type': support_type}) + runtime_provider = SimpleNamespace(provider_entity=SimpleNamespace(requester=requester_name)) + model_mgr = SimpleNamespace( + provider_dict={'p1': runtime_provider}, + get_available_requester_manifest_by_name=lambda name: manifest if name == requester_name else None, + ) + return SimpleNamespace(model_mgr=model_mgr) + + async def test_allows_supported_type(self): + ap = self._make_ap('cohere-rerank', ['rerank']) + # Should not raise + await _validate_provider_supports(ap, 'p1', 'rerank') + + async def test_rejects_unsupported_type(self): + ap = self._make_ap('cohere-rerank', ['rerank']) + with pytest.raises(ValueError, match='does not support llm'): + await _validate_provider_supports(ap, 'p1', 'llm') + + async def test_allows_when_support_type_missing(self): + # Manifest without support_type must not block (backward compatible) + manifest = SimpleNamespace(spec={}) + runtime_provider = SimpleNamespace(provider_entity=SimpleNamespace(requester='legacy')) + model_mgr = SimpleNamespace( + provider_dict={'p1': runtime_provider}, + get_available_requester_manifest_by_name=lambda name: manifest, + ) + ap = SimpleNamespace(model_mgr=model_mgr) + await _validate_provider_supports(ap, 'p1', 'rerank') + + async def test_allows_when_provider_unknown(self): + ap = self._make_ap('cohere-rerank', ['rerank']) + # Unknown provider uuid -> no entry -> no block + await _validate_provider_supports(ap, 'missing', 'llm') + + async def test_degrades_when_model_mgr_incomplete(self): + # A bare ap without a usable model_mgr must not raise (defensive) + ap = SimpleNamespace(model_mgr=SimpleNamespace()) + await _validate_provider_supports(ap, 'p1', 'llm') diff --git a/tests/unit_tests/api/service/test_pipeline_service.py b/tests/unit_tests/api/service/test_pipeline_service.py index a84adab8f..fade30372 100644 --- a/tests/unit_tests/api/service/test_pipeline_service.py +++ b/tests/unit_tests/api/service/test_pipeline_service.py @@ -215,13 +215,7 @@ class TestPipelineServiceCreatePipeline: ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() ap.instance_config = SimpleNamespace() - ap.instance_config.data = { - 'system': { - 'limitation': { - 'max_pipelines': 2 - } - } - } + ap.instance_config.data = {'system': {'limitation': {'max_pipelines': 2}}} ap.pipeline_mgr = SimpleNamespace() ap.pipeline_mgr.load_pipeline = AsyncMock() ap.ver_mgr = SimpleNamespace() @@ -229,9 +223,7 @@ class TestPipelineServiceCreatePipeline: mock_result = _create_mock_result([_create_mock_pipeline(), _create_mock_pipeline()]) ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) - ap.persistence_mgr.serialize_model = Mock( - return_value={'uuid': 'uuid-1', 'name': 'Pipeline 1'} - ) + ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'uuid-1', 'name': 'Pipeline 1'}) service = PipelineService(ap) @@ -258,14 +250,14 @@ class TestPipelineServiceCreatePipeline: # Mock persistence for insert ap.persistence_mgr.execute_async = AsyncMock() - ap.persistence_mgr.serialize_model = Mock( - return_value={'uuid': 'new-uuid', 'name': 'New Pipeline'} - ) + ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid', 'name': 'New Pipeline'}) # Mock the file read for default config - patch at the utils module level default_config = {'trigger': {}, 'safety': {}, 'ai': {}, 'output': {}} with patch('builtins.open', mock_open(read_data=json.dumps(default_config))): - with patch('langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'): + with patch( + 'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json' + ): bot_uuid = await service.create_pipeline({'name': 'New Pipeline'}) # Verify @@ -286,7 +278,9 @@ class TestPipelineServiceCreatePipeline: service = PipelineService(ap) service.get_pipelines = AsyncMock(return_value=[]) - service.get_pipeline = AsyncMock(return_value={'uuid': 'new-uuid', 'name': 'Default Pipeline', 'is_default': True}) + service.get_pipeline = AsyncMock( + return_value={'uuid': 'new-uuid', 'name': 'Default Pipeline', 'is_default': True} + ) ap.persistence_mgr.execute_async = AsyncMock() ap.persistence_mgr.serialize_model = Mock( @@ -296,7 +290,9 @@ class TestPipelineServiceCreatePipeline: # Mock the file read default_config = {} with patch('builtins.open', mock_open(read_data=json.dumps(default_config))): - with patch('langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'): + with patch( + 'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json' + ): await service.create_pipeline({'name': 'Default Pipeline'}, default=True) # Verify - execute was called @@ -316,10 +312,12 @@ class TestPipelineServiceCreatePipeline: service = PipelineService(ap) service.get_pipelines = AsyncMock(return_value=[]) - service.get_pipeline = AsyncMock(return_value={ - 'uuid': 'new-uuid', - 'extensions_preferences': {}, - }) + service.get_pipeline = AsyncMock( + return_value={ + 'uuid': 'new-uuid', + 'extensions_preferences': {}, + } + ) insert_params = [] @@ -339,7 +337,9 @@ class TestPipelineServiceCreatePipeline: default_config = {} with patch('builtins.open', mock_open(read_data=json.dumps(default_config))): - with patch('langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'): + with patch( + 'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json' + ): await service.create_pipeline({'name': 'New Pipeline'}) assert len(insert_params) == 1 @@ -348,11 +348,14 @@ class TestPipelineServiceCreatePipeline: 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': [], + 'mcp_resources': [], + 'mcp_resource_agent_read_enabled': True, } class _MockResultWithBots: """Helper class to mock SQLAlchemy result with iterable .all() method.""" + def __init__(self, bots_list): self._bots_list = bots_list @@ -428,6 +431,7 @@ class TestPipelineServiceUpdatePipeline: # 1. UPDATE (line 125) - returns Mock (no result needed) # 2. SELECT bots (line 136) - returns bot_result with .all() call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -528,13 +532,7 @@ class TestPipelineServiceCopyPipeline: ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() ap.instance_config = SimpleNamespace() - ap.instance_config.data = { - 'system': { - 'limitation': { - 'max_pipelines': 2 - } - } - } + ap.instance_config.data = {'system': {'limitation': {'max_pipelines': 2}}} ap.pipeline_mgr = SimpleNamespace() ap.pipeline_mgr.load_pipeline = AsyncMock() ap.ver_mgr = SimpleNamespace() @@ -542,10 +540,12 @@ class TestPipelineServiceCopyPipeline: service = PipelineService(ap) # Mock get_pipelines to return 2 pipelines - service.get_pipelines = AsyncMock(return_value=[ - {'uuid': 'uuid-1', 'name': 'Pipeline 1'}, - {'uuid': 'uuid-2', 'name': 'Pipeline 2'}, - ]) + service.get_pipelines = AsyncMock( + return_value=[ + {'uuid': 'uuid-1', 'name': 'Pipeline 1'}, + {'uuid': 'uuid-2', 'name': 'Pipeline 2'}, + ] + ) # Execute & Verify with pytest.raises(ValueError, match='Maximum number of pipelines'): @@ -642,9 +642,7 @@ class TestPipelineServiceCopyPipeline: service = PipelineService(ap) service.get_pipelines = AsyncMock(return_value=[]) ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=original)) - ap.persistence_mgr.serialize_model = Mock( - return_value={'uuid': 'copy-uuid', 'is_default': False} - ) + ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'copy-uuid', 'is_default': False}) service.get_pipeline = AsyncMock(return_value={'uuid': 'copy-uuid', 'is_default': False}) @@ -681,11 +679,10 @@ class TestPipelineServiceUpdatePipelineExtensions: ap.pipeline_mgr.remove_pipeline = AsyncMock() ap.pipeline_mgr.load_pipeline = AsyncMock() - original_pipeline = _create_mock_pipeline( - extensions_preferences={'enable_all_plugins': True, 'plugins': []} - ) + original_pipeline = _create_mock_pipeline(extensions_preferences={'enable_all_plugins': True, 'plugins': []}) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -700,7 +697,7 @@ class TestPipelineServiceUpdatePipelineExtensions: 'extensions_preferences': { 'enable_all_plugins': False, 'plugins': [{'plugin_uuid': 'plugin-1'}], - } + }, } ) @@ -711,7 +708,7 @@ class TestPipelineServiceUpdatePipelineExtensions: 'extensions_preferences': { 'enable_all_plugins': False, 'plugins': [{'plugin_uuid': 'plugin-1'}], - } + }, } ) @@ -738,6 +735,7 @@ class TestPipelineServiceUpdatePipelineExtensions: original_pipeline = _create_mock_pipeline() call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -752,7 +750,7 @@ class TestPipelineServiceUpdatePipelineExtensions: 'extensions_preferences': { 'enable_all_mcp_servers': False, 'mcp_servers': ['mcp-server-1'], - } + }, } ) @@ -794,6 +792,7 @@ class TestPipelineServiceUpdatePipelineExtensions: ) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -817,6 +816,47 @@ class TestPipelineServiceUpdatePipelineExtensions: # Verify - persistence was called ap.persistence_mgr.execute_async.assert_called() + async def test_update_extensions_preserves_mcp_resource_agent_read_when_omitted(self): + """Does not reset mcp_resource_agent_read_enabled when omitted by older clients.""" + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace() + ap.pipeline_mgr = SimpleNamespace() + ap.pipeline_mgr.remove_pipeline = AsyncMock() + ap.pipeline_mgr.load_pipeline = AsyncMock() + + original_pipeline = _create_mock_pipeline( + extensions_preferences={ + 'enable_all_plugins': True, + 'enable_all_mcp_servers': True, + 'plugins': [], + 'mcp_servers': [], + 'mcp_resources': [{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}], + 'mcp_resource_agent_read_enabled': False, + } + ) + + call_count = 0 + + async def mock_execute(query): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _create_mock_result(first_item=original_pipeline) + return Mock() + + ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) + ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'test-uuid'}) + + service = PipelineService(ap) + service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid'}) + + await service.update_pipeline_extensions('test-uuid', bound_plugins=[]) + + assert original_pipeline.extensions_preferences['mcp_resource_agent_read_enabled'] is False + assert original_pipeline.extensions_preferences['mcp_resources'] == [ + {'server_uuid': 'srv-1', 'uri': 'file:///README.md'} + ] + class TestDefaultStageOrder: """Tests for default_stage_order constant.""" diff --git a/tests/unit_tests/api/service/test_provider_service.py b/tests/unit_tests/api/service/test_provider_service.py index 4c3f818d4..8b308af8d 100644 --- a/tests/unit_tests/api/service/test_provider_service.py +++ b/tests/unit_tests/api/service/test_provider_service.py @@ -245,12 +245,14 @@ class TestModelProviderServiceCreateProvider: service = ModelProviderService(ap) # Execute - provider_uuid = await service.create_provider({ - 'name': 'New Provider', - 'requester': 'openai', - 'base_url': 'https://api.openai.com', - 'api_keys': ['key'], - }) + provider_uuid = await service.create_provider( + { + 'name': 'New Provider', + 'requester': 'openai', + 'base_url': 'https://api.openai.com', + 'api_keys': ['key'], + } + ) # Verify - UUID is generated assert provider_uuid is not None @@ -274,12 +276,14 @@ class TestModelProviderServiceCreateProvider: service = ModelProviderService(ap) # Execute - result_uuid = await service.create_provider({ - 'name': 'Runtime Provider', - 'requester': 'openai', - 'base_url': 'https://api.openai.com', - 'api_keys': ['key'], - }) + result_uuid = await service.create_provider( + { + 'name': 'Runtime Provider', + 'requester': 'openai', + 'base_url': 'https://api.openai.com', + 'api_keys': ['key'], + } + ) # Verify - provider added to runtime dict and UUID generated ap.model_mgr.load_provider.assert_called_once() @@ -302,10 +306,13 @@ class TestModelProviderServiceUpdateProvider: service = ModelProviderService(ap) # Execute - await service.update_provider('existing-uuid', { - 'uuid': 'should-be-removed', # Will be removed - 'name': 'Updated Name', - }) + await service.update_provider( + 'existing-uuid', + { + 'uuid': 'should-be-removed', # Will be removed + 'name': 'Updated Name', + }, + ) # Verify - reload called ap.model_mgr.reload_provider.assert_called_once_with('existing-uuid') @@ -364,6 +371,7 @@ class TestModelProviderServiceDeleteProvider: rerank_result.first = Mock(return_value=None) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -396,6 +404,7 @@ class TestModelProviderServiceDeleteProvider: rerank_result.first = Mock(return_value=Mock(spec=RerankModel)) # Has rerank model call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -454,6 +463,7 @@ class TestModelProviderServiceGetProviderModelCounts: rerank_result.scalar = Mock(return_value=1) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -637,9 +647,7 @@ class TestModelProviderServiceUpdateSpaceModelProviderApiKeys: await service.update_space_model_provider_api_keys('space-api-key') # Verify - update and reload called for Space provider UUID - ap.model_mgr.reload_provider.assert_called_once_with( - '00000000-0000-0000-0000-000000000000' - ) + ap.model_mgr.reload_provider.assert_called_once_with('00000000-0000-0000-0000-000000000000') class TestModelProviderServiceScanProviderModels: @@ -795,9 +803,7 @@ class TestModelProviderServiceScanProviderModels: runtime_provider.token_mgr = Mock() runtime_provider.token_mgr.get_token = Mock(return_value='token') runtime_provider.token_mgr.tokens = ['token'] - runtime_provider.requester.scan_models = AsyncMock( - side_effect=NotImplementedError('scan not supported') - ) + runtime_provider.requester.scan_models = AsyncMock(side_effect=NotImplementedError('scan not supported')) ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider) service = ModelProviderService(ap) @@ -848,9 +854,7 @@ class TestModelProviderServiceScanProviderModels: ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider) # Mock existing LLM model - ap.llm_model_service.get_llm_models_by_provider = AsyncMock( - return_value=[{'name': 'Existing Model'}] - ) + ap.llm_model_service.get_llm_models_by_provider = AsyncMock(return_value=[{'name': 'Existing Model'}]) ap.embedding_models_service.get_embedding_models_by_provider = AsyncMock(return_value=[]) service = ModelProviderService(ap) @@ -863,4 +867,4 @@ class TestModelProviderServiceScanProviderModels: assert existing_model['already_added'] is True new_model = next(m for m in result['models'] if m['name'] == 'New Model') - assert new_model['already_added'] is False \ No newline at end of file + assert new_model['already_added'] is False diff --git a/tests/unit_tests/api/service/test_space_service.py b/tests/unit_tests/api/service/test_space_service.py index 968753133..f48b18937 100644 --- a/tests/unit_tests/api/service/test_space_service.py +++ b/tests/unit_tests/api/service/test_space_service.py @@ -393,14 +393,16 @@ class TestSpaceServiceRefreshToken: # Mock HTTP response mock_response = MagicMock() mock_response.status = 200 - mock_response.json = AsyncMock(return_value={ - 'code': 0, - 'data': { - 'access_token': 'new_access_token', - 'refresh_token': 'new_refresh_token', - 'expires_in': 3600, + mock_response.json = AsyncMock( + return_value={ + 'code': 0, + 'data': { + 'access_token': 'new_access_token', + 'refresh_token': 'new_refresh_token', + 'expires_in': 3600, + }, } - }) + ) with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session: mock_session_obj = MagicMock() @@ -429,10 +431,12 @@ class TestSpaceServiceRefreshToken: # Mock HTTP response with error mock_response = MagicMock() mock_response.status = 200 - mock_response.json = AsyncMock(return_value={ - 'code': 1, - 'msg': 'Invalid refresh token', - }) + mock_response.json = AsyncMock( + return_value={ + 'code': 1, + 'msg': 'Invalid refresh token', + } + ) mock_response.text = AsyncMock(return_value='{"code":1,"msg":"Invalid refresh token"}') with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session: @@ -489,14 +493,16 @@ class TestSpaceServiceExchangeOAuthCode: # Mock HTTP response mock_response = MagicMock() mock_response.status = 200 - mock_response.json = AsyncMock(return_value={ - 'code': 0, - 'data': { - 'access_token': 'new_access_token', - 'refresh_token': 'new_refresh_token', - 'expires_in': 3600, + mock_response.json = AsyncMock( + return_value={ + 'code': 0, + 'data': { + 'access_token': 'new_access_token', + 'refresh_token': 'new_refresh_token', + 'expires_in': 3600, + }, } - }) + ) with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session: mock_session_obj = MagicMock() @@ -555,13 +561,15 @@ class TestSpaceServiceGetUserInfoRaw: # Mock HTTP response mock_response = MagicMock() mock_response.status = 200 - mock_response.json = AsyncMock(return_value={ - 'code': 0, - 'data': { - 'email': 'test@example.com', - 'credits': 100, + mock_response.json = AsyncMock( + return_value={ + 'code': 0, + 'data': { + 'email': 'test@example.com', + 'credits': 100, + }, } - }) + ) with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session: mock_session_obj = MagicMock() @@ -669,27 +677,29 @@ class TestSpaceServiceGetModels: # Mock HTTP response with proper model data matching SpaceModel schema mock_response = MagicMock() mock_response.status = 200 - mock_response.json = AsyncMock(return_value={ - 'code': 0, - 'data': { - 'models': [ - { - 'uuid': 'uuid-1', - 'model_id': 'model-1', - 'provider': 'provider-1', - 'category': 'chat', - 'status': 'active', - }, - { - 'uuid': 'uuid-2', - 'model_id': 'model-2', - 'provider': 'provider-2', - 'category': 'chat', - 'status': 'active', - }, - ] + mock_response.json = AsyncMock( + return_value={ + 'code': 0, + 'data': { + 'models': [ + { + 'uuid': 'uuid-1', + 'model_id': 'model-1', + 'provider': 'provider-1', + 'category': 'chat', + 'status': 'active', + }, + { + 'uuid': 'uuid-2', + 'model_id': 'model-2', + 'provider': 'provider-2', + 'category': 'chat', + 'status': 'active', + }, + ] + }, } - }) + ) with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session: mock_session_obj = MagicMock() @@ -775,4 +785,4 @@ class TestSpaceServiceCreditsCache: # Verify - cache updated assert result == 500 assert 'test@example.com' in service._credits_cache - assert service._credits_cache['test@example.com'][0] == 500 \ No newline at end of file + assert service._credits_cache['test@example.com'][0] == 500 diff --git a/tests/unit_tests/api/service/test_user_service.py b/tests/unit_tests/api/service/test_user_service.py index 54d0674e0..c5d37f167 100644 --- a/tests/unit_tests/api/service/test_user_service.py +++ b/tests/unit_tests/api/service/test_user_service.py @@ -495,6 +495,7 @@ class TestUserServiceCreateOrUpdateSpaceUser: # First call (line 138) returns None, second call (line 194) returns new_user call_count = 0 + async def mock_get_by_space_uuid(uuid): nonlocal call_count call_count += 1 @@ -565,6 +566,7 @@ class TestUserServiceCreateOrUpdateSpaceUser: # First call (line 138) returns None, second call (line 194) returns new_user call_count = 0 + async def mock_get_by_space_uuid(uuid): nonlocal call_count call_count += 1 @@ -605,4 +607,4 @@ class TestUserServiceCreateUserLock: # Verify lock exists assert hasattr(service, '_create_user_lock') - assert service._create_user_lock is not None \ No newline at end of file + assert service._create_user_lock is not None diff --git a/tests/unit_tests/api/service/test_webhook_service.py b/tests/unit_tests/api/service/test_webhook_service.py index ef2469c1e..7a5a075ef 100644 --- a/tests/unit_tests/api/service/test_webhook_service.py +++ b/tests/unit_tests/api/service/test_webhook_service.py @@ -132,6 +132,7 @@ class TestWebhookServiceCreateWebhook: # execute_async returns different results call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -181,6 +182,7 @@ class TestWebhookServiceCreateWebhook: ) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -217,6 +219,7 @@ class TestWebhookServiceCreateWebhook: created_webhook = _create_mock_webhook(webhook_id=1, enabled=False) call_count = 0 + async def mock_execute(query): nonlocal call_count call_count += 1 @@ -225,9 +228,7 @@ class TestWebhookServiceCreateWebhook: return _create_mock_result(first_item=created_webhook) ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) - ap.persistence_mgr.serialize_model = Mock( - return_value={'id': 1, 'enabled': False} - ) + ap.persistence_mgr.serialize_model = Mock(return_value={'id': 1, 'enabled': False}) service = WebhookService(ap) @@ -503,4 +504,4 @@ class TestWebhookServiceGetEnabledWebhooks: result = await service.get_enabled_webhooks() # Verify - should be empty (SQL would filter disabled) - assert result == [] \ No newline at end of file + assert result == [] diff --git a/tests/unit_tests/api/test_apikey_service.py b/tests/unit_tests/api/test_apikey_service.py index 67b6737ba..2065ae3b7 100644 --- a/tests/unit_tests/api/test_apikey_service.py +++ b/tests/unit_tests/api/test_apikey_service.py @@ -12,7 +12,8 @@ from langbot.pkg.api.http.service.apikey import ApiKeyService @pytest.mark.parametrize('api_key', [None, 123, b'lbk_bytes', '', 'plain_key', ' LBK_bad', 'sk-lbk_fake']) async def test_verify_api_key_rejects_non_lbk_keys_without_db_query(api_key): persistence_mgr = SimpleNamespace(execute_async=AsyncMock()) - service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr)) + instance_config = SimpleNamespace(data={'api': {'global_api_key': ''}}) + service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr, instance_config=instance_config)) result = await service.verify_api_key(api_key) @@ -32,7 +33,8 @@ async def test_verify_api_key_keeps_db_validation_for_lbk_keys(db_row, expected) query_result = Mock() query_result.first.return_value = db_row persistence_mgr = SimpleNamespace(execute_async=AsyncMock(return_value=query_result)) - service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr)) + instance_config = SimpleNamespace(data={'api': {'global_api_key': ''}}) + service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr, instance_config=instance_config)) result = await service.verify_api_key('lbk_valid_format') diff --git a/tests/unit_tests/api/test_box_controller.py b/tests/unit_tests/api/test_box_controller.py new file mode 100644 index 000000000..d6c968978 --- /dev/null +++ b/tests/unit_tests/api/test_box_controller.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import pytest + +from langbot.pkg.api.http.controller.groups.box_visibility import should_hide_box_runtime_status + + +@pytest.mark.parametrize( + ('edition', 'box_enabled', 'expected'), + [ + ('cloud', False, True), + ('cloud', True, False), + ('cloud', None, False), + ('community', False, False), + ], +) +def test_should_hide_box_runtime_status(edition, box_enabled, expected): + assert should_hide_box_runtime_status(edition, box_enabled) is expected diff --git a/tests/unit_tests/api/test_mcp_controller.py b/tests/unit_tests/api/test_mcp_controller.py new file mode 100644 index 000000000..47f2247f2 --- /dev/null +++ b/tests/unit_tests/api/test_mcp_controller.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import sys +import types +from importlib import import_module +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import quart + +core_app_module = types.ModuleType('langbot.pkg.core.app') +core_app_module.Application = object +sys.modules.setdefault('langbot.pkg.core.app', core_app_module) + + +pytestmark = pytest.mark.asyncio + + +async def _create_test_client(mcp_service: SimpleNamespace): + app = quart.Quart(__name__) + user_service = SimpleNamespace( + verify_jwt_token=AsyncMock(return_value='test@example.com'), + get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')), + ) + ap = SimpleNamespace(mcp_service=mcp_service, user_service=user_service) + MCPRouterGroup = import_module('langbot.pkg.api.http.controller.groups.resources.mcp').MCPRouterGroup + group = MCPRouterGroup(ap, app) + await group.initialize() + return app.test_client() + + +async def test_mcp_server_route_accepts_encoded_slash_name(): + mcp_service = SimpleNamespace( + get_mcp_server_by_name=AsyncMock( + return_value={ + 'uuid': 'test-uuid', + 'name': 'pab1it0/prometheus', + 'enable': True, + 'mode': 'stdio', + 'extra_args': {}, + } + ) + ) + client = await _create_test_client(mcp_service) + + response = await client.get( + '/api/v1/mcp/servers/pab1it0%2Fprometheus', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + mcp_service.get_mcp_server_by_name.assert_awaited_once_with('pab1it0/prometheus') + payload = await response.get_json() + assert payload['data']['server']['name'] == 'pab1it0/prometheus' + + +async def test_mcp_resource_route_accepts_encoded_slash_name(): + mcp_service = SimpleNamespace( + get_mcp_server_by_name=AsyncMock(), + get_mcp_server_resources=AsyncMock(return_value=[]), + get_mcp_server_resource_templates=AsyncMock(return_value=[]), + get_runtime_info=AsyncMock(return_value={'resource_capabilities': {'subscribe': False}}), + ) + client = await _create_test_client(mcp_service) + + response = await client.get( + '/api/v1/mcp/servers/pab1it0%2Fprometheus/resources', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + mcp_service.get_mcp_server_by_name.assert_not_awaited() + mcp_service.get_mcp_server_resources.assert_awaited_once_with('pab1it0/prometheus') + payload = await response.get_json() + assert payload['data']['resource_capabilities'] == {'subscribe': False} diff --git a/tests/unit_tests/box/test_box_connector.py b/tests/unit_tests/box/test_box_connector.py new file mode 100644 index 000000000..ddd4899b0 --- /dev/null +++ b/tests/unit_tests/box/test_box_connector.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from langbot_plugin.box.client import ActionRPCBoxClient +from langbot.pkg.box.connector import BoxRuntimeConnector + + +def make_app(logger: Mock, runtime_endpoint: str = ''): + return SimpleNamespace( + logger=logger, + instance_config=SimpleNamespace( + data={ + 'box': { + 'backend': 'local', + 'runtime': {'endpoint': runtime_endpoint}, + 'local': { + 'profile': 'default', + 'allowed_mount_roots': [], + 'default_workspace': '', + }, + 'e2b': {'api_key': '', 'api_url': '', 'template': ''}, + } + } + ), + ) + + +def test_box_runtime_connector_stdio_when_no_url(monkeypatch: pytest.MonkeyPatch): + """Without runtime.endpoint, on a non-Docker Unix platform, use stdio.""" + monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux') + monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False) + connector = BoxRuntimeConnector(make_app(Mock())) + + assert connector._uses_websocket() is False + assert isinstance(connector.client, ActionRPCBoxClient) + + +def test_box_runtime_connector_ws_when_url_configured(monkeypatch: pytest.MonkeyPatch): + """With an explicit runtime.endpoint, always use WebSocket.""" + monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux') + monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False) + logger = Mock() + connector = BoxRuntimeConnector(make_app(logger, runtime_endpoint='http://box-runtime:5410')) + + assert connector._uses_websocket() is True + assert isinstance(connector.client, ActionRPCBoxClient) + + +def test_box_runtime_connector_ws_in_docker(monkeypatch: pytest.MonkeyPatch): + """Inside Docker (no explicit URL), use WebSocket to reach a sibling container.""" + monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'docker') + monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False) + connector = BoxRuntimeConnector(make_app(Mock())) + + assert connector._uses_websocket() is True + assert connector.ws_relay_base_url == 'http://langbot_box:5410' + + +def test_box_runtime_connector_ws_with_standalone_flag(monkeypatch: pytest.MonkeyPatch): + """With --standalone-box flag, use WebSocket even on a local Unix platform.""" + monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux') + monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', True) + connector = BoxRuntimeConnector(make_app(Mock())) + + assert connector._uses_websocket() is True + + +def test_box_runtime_connector_ws_relay_url_default(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux') + monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False) + connector = BoxRuntimeConnector(make_app(Mock())) + + assert connector.ws_relay_base_url == 'http://127.0.0.1:5410' + + +def test_box_runtime_connector_ws_relay_url_explicit(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux') + monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False) + connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410')) + assert connector.ws_relay_base_url == 'http://box-runtime:5410' + + +def test_box_runtime_connector_dispose_terminates_subprocess(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux') + monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False) + logger = Mock() + connector = BoxRuntimeConnector(make_app(logger)) + subprocess = Mock() + subprocess.returncode = None + handler_task = Mock() + ctrl_task = Mock() + connector._subprocess = subprocess + connector._handler_task = handler_task + connector._ctrl_task = ctrl_task + + connector.dispose() + + subprocess.terminate.assert_called_once() + handler_task.cancel.assert_called_once() + ctrl_task.cancel.assert_called_once() + assert connector._handler_task is None + assert connector._ctrl_task is None diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py new file mode 100644 index 000000000..4d66ec8f8 --- /dev/null +++ b/tests/unit_tests/box/test_box_service.py @@ -0,0 +1,1964 @@ +from __future__ import annotations + +import asyncio +import datetime as dt +import os +import tempfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + +from langbot_plugin.box.backend import BaseSandboxBackend +from langbot_plugin.box.client import BoxRuntimeClient, ActionRPCBoxClient +from langbot_plugin.box.errors import ( + BoxBackendUnavailableError, + BoxSessionConflictError, + BoxSessionNotFoundError, + BoxValidationError, +) +from langbot_plugin.box.models import ( + BUILTIN_PROFILES, + BoxExecutionResult, + BoxExecutionStatus, + BoxHostMountMode, + BoxManagedProcessSpec, + BoxNetworkMode, + BoxSessionInfo, + BoxSpec, +) +from langbot_plugin.box.runtime import BoxRuntime +from langbot.pkg.box.service import BoxService + +_UTC = dt.timezone.utc + + +class _InProcessBoxRuntimeClient(BoxRuntimeClient): + """Test-only client that wraps a BoxRuntime in-process (no HTTP).""" + + def __init__(self, logger, runtime=None): + self._runtime = runtime or BoxRuntime(logger=logger) + + async def initialize(self): + await self._runtime.initialize() + + async def execute(self, spec): + return await self._runtime.execute(spec) + + async def shutdown(self): + await self._runtime.shutdown() + + async def get_status(self): + return await self._runtime.get_status() + + async def get_sessions(self): + return self._runtime.get_sessions() + + async def get_backend_info(self): + return await self._runtime.get_backend_info() + + async def delete_session(self, session_id): + await self._runtime.delete_session(session_id) + + async def create_session(self, spec): + return await self._runtime.create_session(spec) + + async def start_managed_process(self, session_id: str, spec: BoxManagedProcessSpec): + return await self._runtime.start_managed_process(session_id, spec) + + async def get_managed_process(self, session_id: str, process_id: str = 'default'): + return self._runtime.get_managed_process(session_id, process_id) + + async def stop_managed_process(self, session_id: str, process_id: str = 'default'): + await self._runtime.stop_managed_process(session_id, process_id) + + async def get_session(self, session_id: str): + return self._runtime.get_session(session_id) + + async def init(self, config: dict) -> None: + self._runtime.init(config) + + +class FakeBackend(BaseSandboxBackend): + def __init__(self, logger: Mock, available: bool = True): + super().__init__(logger) + self.name = 'fake' + self.available = available + self.start_calls: list[str] = [] + self.start_specs: list[BoxSpec] = [] + self.exec_calls: list[tuple[str, str]] = [] + self.stop_calls: list[str] = [] + + async def is_available(self) -> bool: + return self.available + + async def start_session(self, spec: BoxSpec) -> BoxSessionInfo: + self.start_calls.append(spec.session_id) + self.start_specs.append(spec) + now = dt.datetime.now(_UTC) + return BoxSessionInfo( + session_id=spec.session_id, + backend_name=self.name, + backend_session_id=f'backend-{spec.session_id}', + image=spec.image, + network=spec.network, + host_path=spec.host_path, + host_path_mode=spec.host_path_mode, + mount_path=spec.mount_path, + cpus=spec.cpus, + memory_mb=spec.memory_mb, + pids_limit=spec.pids_limit, + read_only_rootfs=spec.read_only_rootfs, + created_at=now, + last_used_at=now, + ) + + async def exec(self, session: BoxSessionInfo, spec: BoxSpec) -> BoxExecutionResult: + self.exec_calls.append((session.session_id, spec.cmd)) + return BoxExecutionResult( + session_id=session.session_id, + backend_name=self.name, + status=BoxExecutionStatus.COMPLETED, + exit_code=0, + stdout=f'executed: {spec.cmd}', + stderr='', + duration_ms=12, + ) + + async def stop_session(self, session: BoxSessionInfo): + self.stop_calls.append(session.session_id) + + +def make_query(query_id: int = 42) -> pipeline_query.Query: + return pipeline_query.Query.model_construct( + query_id=query_id, + launcher_type='person', + launcher_id='test_user', + sender_id='test_user', + variables={ + 'launcher_type': 'person', + 'launcher_id': 'test_user', + 'sender_id': 'test_user', + 'query_id': str(query_id), + }, + ) + + +def make_app( + logger: Mock, + allowed_mount_roots: list[str] | None = None, + profile: str = 'default', + host_root: str = '', + workspace_quota_mb: int | None = None, + enabled: bool = True, + force_box_session_id_template: str = '', +): + box_config = { + 'enabled': enabled, + 'backend': 'local', + 'runtime': {'endpoint': ''}, + 'local': { + 'profile': profile, + 'host_root': host_root, + 'allowed_mount_roots': allowed_mount_roots or [], + 'default_workspace': '', + }, + 'e2b': {'api_key': '', 'api_url': '', 'template': ''}, + } + if workspace_quota_mb is not None: + box_config['local']['workspace_quota_mb'] = workspace_quota_mb + + return SimpleNamespace( + logger=logger, + instance_config=SimpleNamespace( + data={ + 'box': box_config, + 'system': {'limitation': {'force_box_session_id_template': force_box_session_id_template}}, + } + ), + ) + + +@pytest.mark.asyncio +async def test_box_service_without_explicit_client_initializes_internal_connector(monkeypatch: pytest.MonkeyPatch): + connector = Mock() + connector.client = Mock() + connector.initialize = AsyncMock() + + monkeypatch.setattr('langbot.pkg.box.service.BoxRuntimeConnector', Mock(return_value=connector)) + + service = BoxService(make_app(Mock())) + await service.initialize() + + assert service.client is connector.client + connector.initialize.assert_awaited_once() + + +class TestSharesFilesystemWithBox: + """``shares_filesystem_with_box`` must reflect the real LangBot<->Box + filesystem topology, which is derived from the connector transport: + + - stdio (local child process) → shared filesystem → True + - WebSocket (Docker / sidecar / --standalone-box / remote) → separated → False + + This drives whether LangBot validates Box-reported skill paths locally. + Getting it wrong silently drops every skill in separated deployments. + """ + + def test_true_for_stdio_connector(self, monkeypatch: pytest.MonkeyPatch): + # Non-Docker Unix, no endpoint, not standalone → stdio transport. + monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux') + monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False) + + service = BoxService(make_app(Mock())) + + assert service._runtime_connector is not None + assert service._runtime_connector.uses_websocket() is False + assert service.shares_filesystem_with_box is True + + def test_false_for_websocket_connector_via_endpoint(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux') + monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False) + app = make_app(Mock()) + app.instance_config.data['box']['runtime']['endpoint'] = 'ws://pod-x-box:5410' + + service = BoxService(app) + + assert service._runtime_connector is not None + assert service._runtime_connector.uses_websocket() is True + assert service.shares_filesystem_with_box is False + + def test_false_for_websocket_connector_in_docker(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'docker') + monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False) + + service = BoxService(make_app(Mock())) + + assert service.shares_filesystem_with_box is False + + def test_false_when_client_injected_without_connector(self): + # Injected client (no connector) → unknown topology → conservative False + # so LangBot never wrongly drops Box-reported skills. + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + + assert service._runtime_connector is None + assert service.shares_filesystem_with_box is False + + def test_explicit_override_wins(self): + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + + service._shares_filesystem_with_box_override = True + assert service.shares_filesystem_with_box is True + + service._shares_filesystem_with_box_override = False + assert service.shares_filesystem_with_box is False + + +def test_separated_box_runtime_does_not_create_default_workspace_in_langbot(tmp_path): + logger = Mock() + runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300) + host_root = tmp_path / 'box' + service = BoxService(make_app(logger, host_root=str(host_root)), client=_InProcessBoxRuntimeClient(logger, runtime)) + service._shares_filesystem_with_box_override = False + + service._ensure_default_workspace() + + assert not (host_root / 'default').exists() + + +def test_separated_box_runtime_allows_box_owned_missing_host_path(tmp_path): + logger = Mock() + runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300) + host_root = tmp_path / 'box' + service = BoxService(make_app(logger, host_root=str(host_root)), client=_InProcessBoxRuntimeClient(logger, runtime)) + service._shares_filesystem_with_box_override = False + + spec = service.build_spec({'cmd': 'echo hi', 'session_id': 'missing-host-path'}) + + assert spec.host_path == str(host_root / 'default') + assert not (host_root / 'default').exists() + + +@pytest.mark.asyncio +async def test_box_service_get_sessions_delegates_to_client(): + client = Mock() + client.get_sessions = AsyncMock(return_value=[{'session_id': 'test-session'}]) + + service = BoxService(make_app(Mock()), client=client) + service._available = True + + sessions = await service.get_sessions() + + assert sessions == [{'session_id': 'test-session'}] + client.get_sessions.assert_awaited_once() + + +def test_box_service_dispose_delegates_to_internal_connector(monkeypatch: pytest.MonkeyPatch): + connector = Mock() + connector.client = Mock() + + monkeypatch.setattr('langbot.pkg.box.service.BoxRuntimeConnector', Mock(return_value=connector)) + + service = BoxService(make_app(Mock())) + service.dispose() + + connector.dispose.assert_called_once() + + +@pytest.mark.asyncio +async def test_box_service_dispose_schedules_shutdown_on_event_loop(monkeypatch: pytest.MonkeyPatch): + connector = Mock() + connector.client = Mock() + connector.dispose = Mock() + + monkeypatch.setattr('langbot.pkg.box.service.BoxRuntimeConnector', Mock(return_value=connector)) + + app = make_app(Mock()) + loop = asyncio.get_running_loop() + app.event_loop = loop + + service = BoxService(app) + service.shutdown = AsyncMock() + + service.dispose() + await asyncio.sleep(0) + + connector.dispose.assert_called_once() + service.shutdown.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_box_runtime_reuses_request_session(): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + await runtime.initialize() + + first = BoxSpec.model_validate({'cmd': 'echo first', 'session_id': 'req-1'}) + second = BoxSpec.model_validate({'cmd': 'echo second', 'session_id': 'req-1'}) + + await runtime.execute(first) + await runtime.execute(second) + + assert backend.start_calls == ['req-1'] + assert backend.exec_calls == [('req-1', 'echo first'), ('req-1', 'echo second')] + + +@pytest.mark.asyncio +async def test_box_service_defaults_session_id_from_query(): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) + await service.initialize() + + result = await service.execute_tool({'command': 'pwd'}, make_query(7)) + + assert result['session_id'] == 'person_test_user' + assert result['ok'] is True + assert backend.start_calls == ['person_test_user'] + + +@pytest.mark.asyncio +async def test_box_service_session_id_uses_query_attributes_without_variables(): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) + await service.initialize() + + query = pipeline_query.Query.model_construct(query_id=7, launcher_type='group', launcher_id='room-1') + result = await service.execute_tool({'command': 'pwd'}, query) + + assert result['session_id'] == 'group_room-1' + assert result['ok'] is True + assert backend.start_calls == ['group_room-1'] + + +@pytest.mark.asyncio +async def test_box_service_session_id_falls_back_to_query_id_for_synthetic_queries(): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) + await service.initialize() + + query = pipeline_query.Query.model_construct(query_id=7) + result = await service.execute_tool({'command': 'pwd'}, query) + + assert result['session_id'] == 'query_7' + assert result['ok'] is True + assert backend.start_calls == ['query_7'] + + +@pytest.mark.asyncio +async def test_box_service_forced_global_scope_overrides_pipeline_template(): + """SaaS guard: a non-empty ``force_box_session_id_template`` pins every + query to one shared sandbox regardless of the pipeline's own scope.""" + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService( + make_app(logger, force_box_session_id_template='{global}'), + client=_InProcessBoxRuntimeClient(logger, runtime), + ) + await service.initialize() + + # Two distinct callers that would otherwise get separate sandboxes. + q1 = pipeline_query.Query.model_construct(query_id=1, launcher_type='group', launcher_id='room-1') + q2 = pipeline_query.Query.model_construct(query_id=2, launcher_type='person', launcher_id='alice') + + r1 = await service.execute_tool({'command': 'pwd'}, q1) + r2 = await service.execute_tool({'command': 'pwd'}, q2) + + assert r1['session_id'] == 'global' + assert r2['session_id'] == 'global' + # Only one sandbox was ever started — the shared global one. + assert backend.start_calls == ['global'] + + +def test_box_service_forced_template_ignores_pipeline_config(): + """The forced template wins even when the pipeline explicitly sets a + per-user scope — proving the override is not bypassable via pipeline config.""" + logger = Mock() + service = BoxService( + make_app(logger, force_box_session_id_template='{global}'), + client=Mock(spec=BoxRuntimeClient), + ) + query = pipeline_query.Query.model_construct( + query_id=7, + launcher_type='person', + launcher_id='test_user', + sender_id='test_user', + pipeline_config={ + 'ai': {'local-agent': {'box-session-id-template': '{launcher_type}_{launcher_id}_{sender_id}'}} + }, + ) + + assert service.resolve_box_session_id(query) == 'global' + + +def test_box_service_empty_forced_template_respects_pipeline_config(): + """An empty/whitespace forced template is a no-op: the pipeline's own + scope template is honoured (default non-SaaS behaviour).""" + logger = Mock() + service = BoxService( + make_app(logger, force_box_session_id_template=' '), + client=Mock(spec=BoxRuntimeClient), + ) + query = pipeline_query.Query.model_construct( + query_id=7, + launcher_type='group', + launcher_id='room-1', + pipeline_config={'ai': {'local-agent': {'box-session-id-template': '{launcher_type}_{launcher_id}'}}}, + ) + + assert service.resolve_box_session_id(query) == 'group_room-1' + + +@pytest.mark.asyncio +async def test_box_service_fails_closed_when_backend_unavailable(): + logger = Mock() + backend = FakeBackend(logger, available=False) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) + await service.initialize() + + with pytest.raises(BoxBackendUnavailableError): + await service.execute_tool({'command': 'echo hello'}, make_query(9)) + + +@pytest.mark.asyncio +async def test_box_service_allows_host_mount_under_configured_root(tmp_path): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + host_dir = tmp_path / 'mounted-workspace' + host_dir.mkdir() + service = BoxService(make_app(logger, [str(tmp_path)]), client=_InProcessBoxRuntimeClient(logger, runtime)) + await service.initialize() + + result = await service.execute_spec_payload( + { + 'cmd': 'pwd', + 'host_path': str(host_dir), + 'host_path_mode': BoxHostMountMode.READ_WRITE.value, + 'session_id': '11', + }, + make_query(11), + ) + + assert result['ok'] is True + assert backend.start_calls == ['11'] + + +@pytest.mark.asyncio +async def test_box_service_uses_default_workspace_when_host_path_omitted(tmp_path): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + host_dir = tmp_path / 'default-workspace' + host_dir.mkdir() + app = make_app(logger, [str(tmp_path)]) + app.instance_config.data['box']['local']['default_workspace'] = str(host_dir) + service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) + await service.initialize() + + result = await service.execute_tool({'command': 'pwd'}, make_query(15)) + + assert result['ok'] is True + assert backend.start_calls == ['person_test_user'] + assert backend.exec_calls == [('person_test_user', 'pwd')] + assert backend.start_specs[0].host_path == os.path.realpath(host_dir) + + +@pytest.mark.asyncio +async def test_box_service_creates_default_workspace_on_initialize(tmp_path): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + allowed_root = tmp_path / 'allowed-root' + allowed_root.mkdir() + default_workspace = allowed_root / 'default-workspace' + app = make_app(logger, [str(allowed_root)]) + app.instance_config.data['box']['local']['default_workspace'] = str(default_workspace) + service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) + service._shares_filesystem_with_box_override = True + + await service.initialize() + + assert default_workspace.is_dir() + + +@pytest.mark.asyncio +async def test_box_service_derives_workspace_and_allowed_root_from_host_root(tmp_path): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + shared_root = tmp_path / 'shared-box-root' + app = make_app(logger, host_root=str(shared_root)) + service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) + service._shares_filesystem_with_box_override = True + + await service.initialize() + + assert service.host_root == os.path.realpath(shared_root) + assert service.default_workspace == os.path.realpath(shared_root / 'default') + assert service.allowed_mount_roots == [os.path.realpath(shared_root)] + assert (shared_root / 'default').is_dir() + + +@pytest.mark.asyncio +async def test_box_service_rejects_host_mount_outside_allowed_roots(tmp_path): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + allowed_root = tmp_path / 'allowed' + disallowed_root = tmp_path / 'disallowed' + allowed_root.mkdir() + disallowed_root.mkdir() + service = BoxService(make_app(logger, [str(allowed_root)]), client=_InProcessBoxRuntimeClient(logger, runtime)) + await service.initialize() + + with pytest.raises(BoxValidationError): + await service.execute_spec_payload( + { + 'cmd': 'pwd', + 'host_path': str(disallowed_root), + 'session_id': '12', + }, + make_query(12), + ) + + +class TestGetSystemGuidance: + """``get_system_guidance`` must ALWAYS advertise the per-query outbox path + when given a ``query_id`` — even with no inbound attachment — so files the + agent generates (QR codes, charts, rendered docs) are actually delivered. + + The wrapper collects the outbox on every turn regardless of inbound files; + before this, the agent was only told the outbox path inside the + inbound-attachment note, so pure-generation turns produced files that were + silently dropped. + """ + + def _service(self, logger=None): + logger = logger or Mock() + runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300) + return BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) + + def test_guidance_includes_outbox_when_query_id_given(self): + service = self._service() + guidance = service.get_system_guidance(42) + assert f'{service.OUTBOX_MOUNT_DIR}/42' in guidance + assert 'delivered to the user automatically' in guidance + + def test_guidance_omits_outbox_without_query_id(self): + service = self._service() + guidance = service.get_system_guidance() + assert service.OUTBOX_MOUNT_DIR not in guidance + # core exec guidance is still present + assert 'exec tool' in guidance + + def test_guidance_outbox_independent_of_inbound_attachments(self): + # A bare query_id (the pure-generation case) still gets the outbox note. + service = self._service() + assert f'{service.OUTBOX_MOUNT_DIR}/0' in service.get_system_guidance(0) + + +@pytest.mark.asyncio +async def test_box_runtime_rejects_host_mount_conflict_in_same_session(tmp_path): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + await runtime.initialize() + + first_host_dir = tmp_path / 'first' + second_host_dir = tmp_path / 'second' + first_host_dir.mkdir() + second_host_dir.mkdir() + + first = BoxSpec.model_validate( + { + 'cmd': 'echo first', + 'session_id': 'req-mount', + 'host_path': os.path.realpath(first_host_dir), + } + ) + second = BoxSpec.model_validate( + { + 'cmd': 'echo second', + 'session_id': 'req-mount', + 'host_path': os.path.realpath(second_host_dir), + } + ) + + await runtime.execute(first) + + with pytest.raises(BoxSessionConflictError): + await runtime.execute(second) + + +@pytest.mark.asyncio +async def test_box_runtime_rejects_resource_limit_conflict_in_same_session(): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + await runtime.initialize() + + first = BoxSpec.model_validate({'cmd': 'echo first', 'session_id': 'req-resource', 'cpus': 1.0}) + second = BoxSpec.model_validate({'cmd': 'echo second', 'session_id': 'req-resource', 'cpus': 2.0}) + + await runtime.execute(first) + + with pytest.raises(BoxSessionConflictError): + await runtime.execute(second) + + +# ── Truncation tests ────────────────────────────────────────────────── + + +class FakeBackendWithOutput(FakeBackend): + """FakeBackend that returns configurable stdout/stderr.""" + + def __init__(self, logger: Mock, stdout: str = '', stderr: str = ''): + super().__init__(logger) + self._stdout = stdout + self._stderr = stderr + + async def exec(self, session: BoxSessionInfo, spec: BoxSpec) -> BoxExecutionResult: + self.exec_calls.append((session.session_id, spec.cmd)) + return BoxExecutionResult( + session_id=session.session_id, + backend_name=self.name, + status=BoxExecutionStatus.COMPLETED, + exit_code=0, + stdout=self._stdout, + stderr=self._stderr, + duration_ms=5, + ) + + +class FakeBackendWritingFiles(FakeBackend): + """Fake backend that writes files into the mounted host workspace during exec.""" + + def __init__(self, logger: Mock, files_to_write: list[tuple[str, int]]): + super().__init__(logger) + self._files_to_write = files_to_write + + async def exec(self, session: BoxSessionInfo, spec: BoxSpec) -> BoxExecutionResult: + self.exec_calls.append((session.session_id, spec.cmd)) + if session.host_path: + for relative_path, size in self._files_to_write: + host_path = os.path.join(session.host_path, relative_path) + os.makedirs(os.path.dirname(host_path), exist_ok=True) + with open(host_path, 'wb') as f: + f.write(b'x' * size) + return BoxExecutionResult( + session_id=session.session_id, + backend_name=self.name, + status=BoxExecutionStatus.COMPLETED, + exit_code=0, + stdout='wrote files', + stderr='', + duration_ms=5, + ) + + +@pytest.mark.asyncio +async def test_truncate_short_output_unchanged(): + logger = Mock() + backend = FakeBackendWithOutput(logger, stdout='hello world') + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime), output_limit_chars=100) + await service.initialize() + + result = await service.execute_tool({'command': 'echo hello'}, make_query(20)) + + assert result['stdout'] == 'hello world' + assert result['stdout_truncated'] is False + + +@pytest.mark.asyncio +async def test_truncate_preserves_head_and_tail(): + logger = Mock() + # Build output: "AAAA...BBB..." where each section is identifiable + head_marker = 'HEAD_START|' + tail_marker = '|TAIL_END' + filler = 'x' * 500 + big_output = f'{head_marker}{filler}{tail_marker}' + + backend = FakeBackendWithOutput(logger, stdout=big_output) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + limit = 100 + service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime), output_limit_chars=limit) + await service.initialize() + + result = await service.execute_tool({'command': 'cat big'}, make_query(21)) + + assert result['stdout_truncated'] is True + stdout = result['stdout'] + # Head part should contain the head marker + assert stdout.startswith(head_marker) + # Tail part should contain the tail marker + assert stdout.endswith(tail_marker) + # Should contain the truncation notice + assert 'characters truncated' in stdout + assert len(stdout) <= limit + + +@pytest.mark.asyncio +async def test_truncate_at_exact_limit_not_truncated(): + logger = Mock() + exact_output = 'a' * 200 + backend = FakeBackendWithOutput(logger, stdout=exact_output) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime), output_limit_chars=200) + await service.initialize() + + result = await service.execute_tool({'command': 'echo a'}, make_query(22)) + + assert result['stdout'] == exact_output + assert result['stdout_truncated'] is False + + +@pytest.mark.asyncio +async def test_truncate_stderr_independently(): + logger = Mock() + backend = FakeBackendWithOutput(logger, stdout='short', stderr='E' * 300) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime), output_limit_chars=100) + await service.initialize() + + result = await service.execute_tool({'command': 'fail'}, make_query(23)) + + assert result['stdout_truncated'] is False + assert result['stderr_truncated'] is True + assert 'characters truncated' in result['stderr'] + assert len(result['stderr']) <= 100 + + +# ── Profile tests ───────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_profile_default_provides_defaults(): + """When tool call omits network/image, profile defaults are used.""" + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) + await service.initialize() + + result = await service.execute_tool({'command': 'echo hi'}, make_query(30)) + + assert result['ok'] is True + spec = backend.start_specs[0] + profile = BUILTIN_PROFILES['default'] + assert spec.network == BoxNetworkMode.OFF + assert spec.image == profile.image + assert spec.timeout_sec == profile.timeout_sec + + +@pytest.mark.asyncio +async def test_profile_unlocked_field_can_be_overridden(): + """Spec payload can override unlocked profile fields.""" + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) + await service.initialize() + + result = await service.execute_spec_payload( + {'cmd': 'echo hi', 'timeout_sec': 60, 'network': 'on', 'session_id': '31'}, + make_query(31), + ) + + assert result['ok'] is True + spec = backend.start_specs[0] + assert spec.timeout_sec == 60 + assert spec.network == BoxNetworkMode.ON + + +@pytest.mark.asyncio +async def test_profile_locked_field_cannot_be_overridden(): + """offline_readonly profile locks network and host_path_mode.""" + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService( + make_app(logger, profile='offline_readonly'), client=_InProcessBoxRuntimeClient(logger, runtime) + ) + await service.initialize() + + result = await service.execute_spec_payload( + {'cmd': 'echo hi', 'network': 'on', 'host_path_mode': 'rw', 'session_id': '32'}, + make_query(32), + ) + + assert result['ok'] is True + spec = backend.start_specs[0] + assert spec.network == BoxNetworkMode.OFF + assert spec.host_path_mode == BoxHostMountMode.READ_ONLY + + +@pytest.mark.asyncio +async def test_profile_timeout_clamped_to_max(): + """timeout_sec exceeding max_timeout_sec is clamped.""" + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) + await service.initialize() + + result = await service.execute_tool({'command': 'echo hi', 'timeout_sec': 999}, make_query(33)) + + assert result['ok'] is True + spec = backend.start_specs[0] + # default profile max_timeout_sec = 120 + assert spec.timeout_sec == 120 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('timeout_value', ['999', 999.0]) +async def test_profile_timeout_clamped_for_coercible_inputs(timeout_value): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) + await service.initialize() + + await service.execute_tool({'command': 'echo hi', 'timeout_sec': timeout_value}, make_query(34)) + + spec = backend.start_specs[0] + assert spec.timeout_sec == 120 + + +def test_unknown_profile_raises_error(): + """Config referencing a non-existent profile name raises immediately.""" + logger = Mock() + runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300) + with pytest.raises(BoxValidationError, match='unknown box profile'): + BoxService(make_app(logger, profile='nonexistent'), client=_InProcessBoxRuntimeClient(logger, runtime)) + + +def test_builtin_profiles_are_consistent(): + """Basic sanity check on all built-in profiles.""" + assert 'default' in BUILTIN_PROFILES + assert 'offline_readonly' in BUILTIN_PROFILES + assert 'network_basic' in BUILTIN_PROFILES + assert 'network_extended' in BUILTIN_PROFILES + + offline = BUILTIN_PROFILES['offline_readonly'] + assert offline.network == BoxNetworkMode.OFF + assert offline.host_path_mode == BoxHostMountMode.READ_ONLY + assert 'network' in offline.locked + assert 'host_path_mode' in offline.locked + assert 'read_only_rootfs' in offline.locked + assert offline.max_timeout_sec <= BUILTIN_PROFILES['default'].max_timeout_sec + + basic = BUILTIN_PROFILES['network_basic'] + assert basic.network == BoxNetworkMode.ON + assert basic.read_only_rootfs is True + + extended = BUILTIN_PROFILES['network_extended'] + assert extended.network == BoxNetworkMode.ON + assert extended.read_only_rootfs is False + assert extended.cpus > BUILTIN_PROFILES['default'].cpus + assert extended.memory_mb > BUILTIN_PROFILES['default'].memory_mb + + +@pytest.mark.asyncio +async def test_profile_default_applies_resource_limits(): + """Default profile resource limits are applied to BoxSpec.""" + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) + await service.initialize() + + await service.execute_tool({'command': 'echo hi'}, make_query(40)) + + spec = backend.start_specs[0] + profile = BUILTIN_PROFILES['default'] + assert spec.cpus == profile.cpus + assert spec.memory_mb == profile.memory_mb + assert spec.pids_limit == profile.pids_limit + assert spec.read_only_rootfs == profile.read_only_rootfs + assert spec.workspace_quota_mb == profile.workspace_quota_mb + + +@pytest.mark.asyncio +async def test_box_service_applies_workspace_quota_from_config(tmp_path): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + host_dir = tmp_path / 'default-workspace' + host_dir.mkdir() + app = make_app(logger, [str(tmp_path)], workspace_quota_mb=32) + app.instance_config.data['box']['local']['default_workspace'] = str(host_dir) + service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) + + await service.initialize() + await service.execute_tool({'command': 'echo hi'}, make_query(43)) + + assert backend.start_specs[0].workspace_quota_mb == 32 + + +@pytest.mark.asyncio +async def test_box_service_rejects_execution_when_workspace_already_exceeds_quota(tmp_path): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + host_dir = tmp_path / 'quota-workspace' + host_dir.mkdir() + (host_dir / 'already-too-large.bin').write_bytes(b'x' * (2 * 1024 * 1024)) + app = make_app(logger, [str(tmp_path)], workspace_quota_mb=1) + app.instance_config.data['box']['local']['default_workspace'] = str(host_dir) + service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) + + await service.initialize() + + with pytest.raises(BoxValidationError, match='workspace quota exceeded before execution'): + await service.execute_tool({'command': 'echo hi'}, make_query(44)) + + assert backend.start_calls == [] + + +@pytest.mark.asyncio +async def test_box_service_rejects_and_cleans_up_when_execution_exceeds_workspace_quota(tmp_path): + logger = Mock() + backend = FakeBackendWritingFiles(logger, files_to_write=[('output.bin', 2 * 1024 * 1024)]) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + host_dir = tmp_path / 'quota-workspace-post' + host_dir.mkdir() + app = make_app(logger, [str(tmp_path)], workspace_quota_mb=1) + app.instance_config.data['box']['local']['default_workspace'] = str(host_dir) + service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) + + await service.initialize() + + with pytest.raises(BoxValidationError, match='workspace quota exceeded after execution'): + await service.execute_tool({'command': 'generate-output'}, make_query(45)) + + assert backend.start_calls == ['person_test_user'] + assert backend.stop_calls == ['person_test_user'] + + +@pytest.mark.asyncio +async def test_profile_offline_readonly_locks_read_only_rootfs(): + """offline_readonly locks read_only_rootfs so it cannot be overridden.""" + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService( + make_app(logger, profile='offline_readonly'), client=_InProcessBoxRuntimeClient(logger, runtime) + ) + await service.initialize() + + await service.execute_spec_payload( + {'cmd': 'echo hi', 'read_only_rootfs': False, 'session_id': '41'}, make_query(41) + ) + + spec = backend.start_specs[0] + assert spec.read_only_rootfs is True + + +@pytest.mark.asyncio +async def test_profile_network_extended_has_relaxed_limits(): + """network_extended profile provides higher resource limits.""" + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService( + make_app(logger, profile='network_extended'), client=_InProcessBoxRuntimeClient(logger, runtime) + ) + await service.initialize() + + await service.execute_tool({'command': 'echo hi'}, make_query(42)) + + spec = backend.start_specs[0] + assert spec.network == BoxNetworkMode.ON + assert spec.cpus == 2.0 + assert spec.memory_mb == 1024 + assert spec.read_only_rootfs is False + + +def test_box_spec_validates_resource_limits(): + """BoxSpec rejects invalid resource limit values.""" + with pytest.raises(Exception): + BoxSpec.model_validate({'cmd': 'echo', 'session_id': 's1', 'cpus': 0}) + with pytest.raises(Exception): + BoxSpec.model_validate({'cmd': 'echo', 'session_id': 's1', 'memory_mb': 10}) + with pytest.raises(Exception): + BoxSpec.model_validate({'cmd': 'echo', 'session_id': 's1', 'pids_limit': 0}) + with pytest.raises(Exception): + BoxSpec.model_validate({'cmd': 'echo', 'session_id': 's1', 'workspace_quota_mb': -1}) + + +# ── Observability tests ─────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_runtime_get_status_reports_backend_and_sessions(): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + await runtime.initialize() + + status = await runtime.get_status() + assert status['backend']['name'] == 'fake' + assert status['backend']['available'] is True + assert status['active_sessions'] == 0 + + await runtime.execute(BoxSpec.model_validate({'cmd': 'echo', 'session_id': 'obs-1'})) + status = await runtime.get_status() + assert status['active_sessions'] == 1 + + +@pytest.mark.asyncio +async def test_runtime_get_sessions_returns_session_info(): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + await runtime.initialize() + + await runtime.execute(BoxSpec.model_validate({'cmd': 'echo', 'session_id': 'obs-2'})) + sessions = runtime.get_sessions() + assert len(sessions) == 1 + assert sessions[0]['session_id'] == 'obs-2' + assert sessions[0]['backend_name'] == 'fake' + assert 'created_at' in sessions[0] + assert 'last_used_at' in sessions[0] + + +@pytest.mark.asyncio +async def test_runtime_get_backend_info_when_no_backend(): + logger = Mock() + backend = FakeBackend(logger, available=False) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + await runtime.initialize() + + info = await runtime.get_backend_info() + assert info['name'] is None + assert info['available'] is False + + +@pytest.mark.asyncio +async def test_service_records_errors_on_failure(): + logger = Mock() + backend = FakeBackend(logger, available=False) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) + await service.initialize() + + with pytest.raises(Exception): + await service.execute_tool({'command': 'echo hello'}, make_query(50)) + + errors = service.get_recent_errors() + assert len(errors) == 1 + assert errors[0]['type'] == 'BoxBackendUnavailableError' + assert errors[0]['query_id'] == '50' + assert 'timestamp' in errors[0] + + +@pytest.mark.asyncio +async def test_service_error_ring_buffer_capped(): + logger = Mock() + backend = FakeBackend(logger, available=False) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) + await service.initialize() + + for i in range(60): + with pytest.raises(Exception): + await service.execute_tool({'command': 'fail'}, make_query(100 + i)) + + errors = service.get_recent_errors() + assert len(errors) == 50 + # Oldest should have been evicted, newest kept + assert errors[0]['query_id'] == '110' + assert errors[-1]['query_id'] == '159' + + +@pytest.mark.asyncio +async def test_service_get_status_aggregates_runtime_and_profile(): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) + await service.initialize() + + status = await service.get_status() + assert status['profile'] == 'default' + assert status['backend']['name'] == 'fake' + assert status['backend']['available'] is True + assert status['active_sessions'] == 0 + assert status['recent_error_count'] == 0 + + +# ── In-process RPC client/server tests ───────────────────────────────── + + +class _QueueConnection: + """In-process Connection backed by asyncio Queues — no real IO.""" + + def __init__(self, rx: asyncio.Queue[str], tx: asyncio.Queue[str]): + self._rx = rx + self._tx = tx + + async def send(self, message: str) -> None: + await self._tx.put(message) + + async def receive(self) -> str: + return await self._rx.get() + + async def close(self) -> None: + pass + + +def _make_queue_connection_pair(): + """Return (client_conn, server_conn) linked by queues.""" + c2s: asyncio.Queue[str] = asyncio.Queue() + s2c: asyncio.Queue[str] = asyncio.Queue() + client_conn = _QueueConnection(rx=s2c, tx=c2s) + server_conn = _QueueConnection(rx=c2s, tx=s2c) + return client_conn, server_conn + + +async def _make_rpc_pair(runtime: BoxRuntime): + """Create an in-process (ActionRPCBoxClient, server_task, client_task) connected via queues.""" + from langbot_plugin.box.server import BoxServerHandler + from langbot_plugin.runtime.io.handler import Handler + + client_conn, server_conn = _make_queue_connection_pair() + + server_handler = BoxServerHandler(server_conn, runtime) + server_task = asyncio.create_task(server_handler.run()) + + client_handler = Handler.__new__(Handler) + Handler.__init__(client_handler, client_conn) + client_task = asyncio.create_task(client_handler.run()) + + client = ActionRPCBoxClient(logger=Mock()) + client.set_handler(client_handler) + + return client, server_task, client_task + + +@pytest.mark.asyncio +async def test_rpc_client_execute(): + """ActionRPCBoxClient correctly calls server and parses result.""" + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + await runtime.initialize() + + client, server_task, client_task = await _make_rpc_pair(runtime) + try: + spec = BoxSpec.model_validate({'cmd': 'echo remote', 'session_id': 'r-1'}) + result = await client.execute(spec) + + assert result.session_id == 'r-1' + assert result.status == BoxExecutionStatus.COMPLETED + assert result.exit_code == 0 + assert result.stdout == 'executed: echo remote' + finally: + server_task.cancel() + client_task.cancel() + await runtime.shutdown() + + +@pytest.mark.asyncio +async def test_rpc_client_get_sessions(): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + await runtime.initialize() + + client, server_task, client_task = await _make_rpc_pair(runtime) + try: + spec = BoxSpec.model_validate({'cmd': 'echo hi', 'session_id': 'r-2'}) + await client.execute(spec) + + sessions = await client.get_sessions() + assert len(sessions) == 1 + assert sessions[0]['session_id'] == 'r-2' + finally: + server_task.cancel() + client_task.cancel() + await runtime.shutdown() + + +@pytest.mark.asyncio +async def test_rpc_client_get_status(): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + await runtime.initialize() + + client, server_task, client_task = await _make_rpc_pair(runtime) + try: + status = await client.get_status() + + assert 'backend' in status + assert 'active_sessions' in status + finally: + server_task.cancel() + client_task.cancel() + await runtime.shutdown() + + +@pytest.mark.asyncio +async def test_rpc_client_get_backend_info(): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + await runtime.initialize() + + client, server_task, client_task = await _make_rpc_pair(runtime) + try: + info = await client.get_backend_info() + + assert info['name'] == 'fake' + assert info['available'] is True + finally: + server_task.cancel() + client_task.cancel() + await runtime.shutdown() + + +# ── RPC-based delete/create/conflict tests ──────────────────────────── + + +@pytest.mark.asyncio +async def test_rpc_client_delete_session(): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + await runtime.initialize() + + client, server_task, client_task = await _make_rpc_pair(runtime) + try: + spec = BoxSpec.model_validate({'cmd': 'echo hi', 'session_id': 'r-del-1'}) + await client.execute(spec) + + await client.delete_session('r-del-1') + + sessions = await client.get_sessions() + assert len(sessions) == 0 + finally: + server_task.cancel() + client_task.cancel() + await runtime.shutdown() + + +@pytest.mark.asyncio +async def test_rpc_client_delete_session_raises_not_found(): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + await runtime.initialize() + + client, server_task, client_task = await _make_rpc_pair(runtime) + try: + with pytest.raises(BoxSessionNotFoundError): + await client.delete_session('nonexistent') + finally: + server_task.cancel() + client_task.cancel() + await runtime.shutdown() + + +@pytest.mark.asyncio +async def test_rpc_client_create_session(): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + await runtime.initialize() + + client, server_task, client_task = await _make_rpc_pair(runtime) + try: + spec = BoxSpec.model_validate({'cmd': 'placeholder', 'session_id': 'r-create-1'}) + info = await client.create_session(spec) + assert info['session_id'] == 'r-create-1' + assert info['backend_name'] == 'fake' + + sessions = await client.get_sessions() + assert len(sessions) == 1 + finally: + server_task.cancel() + client_task.cancel() + await runtime.shutdown() + + +@pytest.mark.asyncio +async def test_rpc_client_exec_raises_conflict_error(): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + await runtime.initialize() + + client, server_task, client_task = await _make_rpc_pair(runtime) + try: + spec1 = BoxSpec.model_validate({'cmd': 'echo first', 'session_id': 'r-conflict-1', 'network': 'off'}) + await client.execute(spec1) + + spec2 = BoxSpec.model_validate({'cmd': 'echo second', 'session_id': 'r-conflict-1', 'network': 'on'}) + with pytest.raises(BoxSessionConflictError): + await client.execute(spec2) + finally: + server_task.cancel() + client_task.cancel() + await runtime.shutdown() + + +# ── BoxHostMountMode.NONE tests ───────────────────────────────────── + + +class TestBoxHostMountModeNone: + def test_none_mode_is_valid_enum(self): + assert BoxHostMountMode.NONE.value == 'none' + + def test_spec_with_none_mode_skips_workdir_check(self): + """When host_path_mode is NONE, workdir validation is skipped.""" + spec = BoxSpec( + session_id='test', + cmd='echo hi', + host_path='/home/user/data', + host_path_mode=BoxHostMountMode.NONE, + workdir='/opt/custom', # Not under /workspace, should be allowed + ) + assert spec.host_path_mode == BoxHostMountMode.NONE + assert spec.workdir == '/opt/custom' + + def test_spec_with_rw_mode_requires_workspace_workdir(self): + """When host_path_mode is RW, workdir must be under mount_path.""" + with pytest.raises(Exception): + BoxSpec( + session_id='test', + cmd='echo hi', + host_path='/home/user/data', + host_path_mode=BoxHostMountMode.READ_WRITE, + workdir='/opt/custom', + ) + + def test_spec_with_ro_mode_requires_workspace_workdir(self): + """When host_path_mode is RO, workdir must be under mount_path.""" + with pytest.raises(Exception): + BoxSpec( + session_id='test', + cmd='echo hi', + host_path='/home/user/data', + host_path_mode=BoxHostMountMode.READ_ONLY, + workdir='/opt/custom', + ) + + def test_spec_with_custom_mount_path_allows_matching_workdir(self): + spec = BoxSpec( + session_id='test', + cmd='echo hi', + host_path='/home/user/data', + host_path_mode=BoxHostMountMode.READ_WRITE, + mount_path='/project', + workdir='/project/src', + ) + assert spec.mount_path == '/project' + assert spec.workdir == '/project/src' + + def test_spec_with_custom_mount_path_rejects_outside_workdir(self): + with pytest.raises(Exception): + BoxSpec( + session_id='test', + cmd='echo hi', + host_path='/home/user/data', + host_path_mode=BoxHostMountMode.READ_WRITE, + mount_path='/project', + workdir='/workspace', + ) + + +class TestBoxDisabledByConfig: + """``box.enabled = false`` must keep the BoxService usable as a status + surface but skip every connection attempt and report unavailable.""" + + @pytest.mark.asyncio + async def test_initialize_skips_connector_when_disabled(self): + logger = Mock() + app = make_app(logger, enabled=False) + client = Mock(spec=BoxRuntimeClient) + client.initialize = AsyncMock() + service = BoxService(app, client=client) + + await service.initialize() + + # The client must not be touched; we did not even open a connection. + client.initialize.assert_not_awaited() + assert service.enabled is False + assert service.available is False + # The reason is captured so the dashboard / UI can show it. + assert 'disabled' in service._connector_error.lower() + + @pytest.mark.asyncio + async def test_get_status_reports_disabled(self): + logger = Mock() + service = BoxService(make_app(logger, enabled=False), client=Mock(spec=BoxRuntimeClient)) + await service.initialize() + + status = await service.get_status() + + assert status['available'] is False + assert status['enabled'] is False + assert 'disabled' in status['connector_error'].lower() + + @pytest.mark.asyncio + async def test_get_status_distinguishes_enabled_but_unavailable(self): + logger = Mock() + client = Mock(spec=BoxRuntimeClient) + client.initialize = AsyncMock(side_effect=RuntimeError('docker daemon not running')) + service = BoxService(make_app(logger, enabled=True), client=client) + + await service.initialize() + + status = await service.get_status() + assert status['available'] is False + assert status['enabled'] is True + assert 'docker daemon' in status['connector_error'] + + @pytest.mark.asyncio + async def test_get_status_downgrades_available_when_backend_dead(self): + """The connector can be healthy while the runtime reports no usable + backend (operator selected nsjail but binary missing, Docker daemon + crashed after handshake, ...). The top-level ``available`` must + reflect the combined state so the dashboard / useBoxStatus hook / + skill_service gate stay consistent with the native-tool gate.""" + logger = Mock() + client = Mock(spec=BoxRuntimeClient) + client.initialize = AsyncMock() + client.get_status = AsyncMock( + return_value={ + 'backend': {'name': 'nsjail', 'available': False}, + 'active_sessions': 0, + } + ) + service = BoxService(make_app(logger, enabled=True), client=client) + await service.initialize() + + status = await service.get_status() + assert status['available'] is False + assert status['enabled'] is True + # The detailed backend object is preserved for the dialog + assert status['backend'] == {'name': 'nsjail', 'available': False} + assert 'nsjail' in status['connector_error'] + + @pytest.mark.asyncio + async def test_get_status_keeps_available_true_when_backend_ok(self): + logger = Mock() + client = Mock(spec=BoxRuntimeClient) + client.initialize = AsyncMock() + client.get_status = AsyncMock( + return_value={ + 'backend': {'name': 'docker', 'available': True}, + 'active_sessions': 2, + } + ) + service = BoxService(make_app(logger, enabled=True), client=client) + await service.initialize() + + status = await service.get_status() + assert status['available'] is True + assert status['backend'] == {'name': 'docker', 'available': True} + # No spurious connector_error overlay when everything is healthy + assert 'connector_error' not in status or not status['connector_error'] + + @pytest.mark.asyncio + async def test_disconnect_callback_is_no_op_when_disabled(self): + logger = Mock() + service = BoxService(make_app(logger, enabled=False), client=Mock(spec=BoxRuntimeClient)) + + # Should be safe to fire; must not flip reconnect state on a disabled + # service. If it tried to schedule a reconnect, the test would hang. + await service._on_runtime_disconnect(connector=Mock()) + + assert service._reconnecting is False + + +class TestBuildSkillExtraMounts: + """Robustness of skill mount construction against a stale skill cache. + + The three sandbox backends behave inconsistently when a skill's + package_root no longer exists on disk (nsjail aborts the whole sandbox + start, Docker silently auto-creates a root-owned empty directory, E2B + silently skips). Mount construction must filter these out up front so + the backend never sees a bad mount. + """ + + def _make_service(self, logger, skills, *, shares_filesystem=True): + app = make_app(logger) + app.skill_mgr = SimpleNamespace(skills=skills) + client = Mock(spec=BoxRuntimeClient) + service = BoxService(app, client=client) + # Tests construct BoxService with an injected client (no connector), so + # set the topology explicitly. Most cases exercise the shared-fs (local + # stdio) path where local package_root validation applies. + service._shares_filesystem_with_box_override = shares_filesystem + return service + + def test_skips_skill_with_missing_package_root(self): + logger = Mock() + with tempfile.TemporaryDirectory() as live_dir: + skills = { + 'alive': {'name': 'alive', 'package_root': live_dir}, + 'ghost': {'name': 'ghost', 'package_root': '/nonexistent/path/should/never/exist'}, + } + service = self._make_service(logger, skills) + query = make_query() + + mounts = service.build_skill_extra_mounts(query) + + assert mounts == [ + { + 'host_path': live_dir, + 'mount_path': '/workspace/.skills/alive', + 'mode': 'rw', + } + ] + # Warning logged so operators can see what was dropped + assert any( + 'ghost' in str(call.args[0]) and 'package_root missing' in str(call.args[0]) + for call in logger.warning.call_args_list + ) + + def test_trusts_box_paths_when_filesystem_not_shared(self): + """In separated deployments (Docker Compose, k8s sidecar, + --standalone-box, remote endpoint) the Box runtime owns its own + filesystem. package_root values it reports are NOT resolvable on the + LangBot side, so LangBot must trust them rather than dropping every + skill via a local isdir() check.""" + logger = Mock() + skills = { + 'a': {'name': 'a', 'package_root': '/box/skills/a'}, + 'b': {'name': 'b', 'package_root': '/box/skills/b'}, + } + service = self._make_service(logger, skills, shares_filesystem=False) + + mounts = service.build_skill_extra_mounts(make_query()) + + assert mounts == [ + {'host_path': '/box/skills/a', 'mount_path': '/workspace/.skills/a', 'mode': 'rw'}, + {'host_path': '/box/skills/b', 'mount_path': '/workspace/.skills/b', 'mode': 'rw'}, + ] + # No skill is dropped, so no "missing" warning should be logged. + assert not any('package_root missing' in str(call.args[0]) for call in logger.warning.call_args_list) + + def test_skips_skill_with_empty_package_root(self): + logger = Mock() + skills = { + 'no_root': {'name': 'no_root', 'package_root': ''}, + 'whitespace': {'name': 'whitespace', 'package_root': ' '}, + } + service = self._make_service(logger, skills) + + assert service.build_skill_extra_mounts(make_query()) == [] + + def test_empty_package_root_skipped_even_when_not_shared(self): + """An empty package_root is always invalid regardless of topology.""" + logger = Mock() + skills = {'no_root': {'name': 'no_root', 'package_root': ''}} + service = self._make_service(logger, skills, shares_filesystem=False) + + assert service.build_skill_extra_mounts(make_query()) == [] + + def test_returns_empty_when_no_skill_manager(self): + logger = Mock() + app = make_app(logger) + # no skill_mgr attribute + service = BoxService(app, client=Mock(spec=BoxRuntimeClient)) + + assert service.build_skill_extra_mounts(make_query()) == [] + + +# ── Attachment passthrough (inbound / outbound) ───────────────────────────── + + +class TestAttachmentHelpers: + def test_sanitize_attachment_name_strips_traversal(self): + assert BoxService._sanitize_attachment_name('../../etc/passwd', 'fb') == 'passwd' + assert BoxService._sanitize_attachment_name('/a/b/c.png', 'fb') == 'c.png' + assert BoxService._sanitize_attachment_name('a b c.txt', 'fb') == 'a_b_c.txt' + assert BoxService._sanitize_attachment_name('', 'fallback.bin') == 'fallback.bin' + assert BoxService._sanitize_attachment_name('...', 'fb.bin') == 'fb.bin' + # weird unicode / shell chars dropped, but keeps a usable name + out = BoxService._sanitize_attachment_name('rm -rf $(x).png', 'fb') + assert '/' not in out and '$' not in out and out.endswith('.png') + + def test_classify_outbound_entries_by_extension(self): + entries = [ + {'name': 'chart.png', 'b64': 'AAA'}, + {'name': 'clip.mp3', 'b64': 'BBB'}, + {'name': 'report.pdf', 'b64': 'CCC'}, + {'name': 'sub/dir/photo.JPG', 'b64': 'DDD'}, + {'name': 'noext', 'b64': 'EEE'}, + {'name': 'skip', 'b64': ''}, # dropped (no payload) + ] + out = BoxService._classify_outbound_entries(entries) + by_name = {a['name']: a for a in out} + assert by_name['chart.png']['type'] == 'Image' + assert by_name['chart.png']['base64'].startswith('data:image/png;base64,') + assert by_name['clip.mp3']['type'] == 'Voice' + assert by_name['clip.mp3']['base64'].startswith('data:audio/mp3;base64,') + assert by_name['report.pdf']['type'] == 'File' + assert by_name['report.pdf']['base64'] == 'CCC' # raw b64, no data: prefix + # nested path collapses to basename, case-insensitive ext + assert by_name['photo.JPG']['type'] == 'Image' + assert by_name['noext']['type'] == 'File' + assert 'skip' not in by_name + + @pytest.mark.asyncio + async def test_component_to_bytes_from_data_uri(self): + import base64 + + raw = b'hello-bytes' + data_uri = 'data:text/plain;base64,' + base64.b64encode(raw).decode() + component = SimpleNamespace(base64=data_uri, url=None, path=None) + result = await BoxService._component_to_bytes(component) + assert result is not None + data, mime = result + assert data == raw + assert mime == 'text/plain' + + @pytest.mark.asyncio + async def test_component_to_bytes_returns_none_when_empty(self): + component = SimpleNamespace(base64=None, url=None, path=None) + assert await BoxService._component_to_bytes(component) is None + + +class TestInboundOutboundRoundTrip: + def _service(self) -> BoxService: + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + service._available = True + return service + + @pytest.mark.asyncio + async def test_materialize_inbound_writes_and_describes(self): + import base64 + + import langbot_plugin.api.entities.builtin.platform.message as platform_message + + service = self._service() + + img_bytes = b'\x89PNG\r\n\x1a\n fake png' + img_b64 = 'data:image/png;base64,' + base64.b64encode(img_bytes).decode() + + query = make_query() + query.message_chain = platform_message.MessageChain( + [ + platform_message.Plain(text='please resize this'), + platform_message.Image(base64=img_b64), + ] + ) + + # Mock the sandbox write path: echo back the written paths. + async def fake_execute_tool(parameters, q): + assert '/workspace/inbox/' in parameters['command'] + return { + 'ok': True, + 'stdout': '["/workspace/inbox/42/image_1.png"]', + 'stderr': '', + } + + service.execute_tool = AsyncMock(side_effect=fake_execute_tool) + + descriptors = await service.materialize_inbound_attachments(query) + assert len(descriptors) == 1 + d = descriptors[0] + assert d['type'] == 'Image' + assert d['path'] == '/workspace/inbox/42/image_1.png' + assert d['size'] == len(img_bytes) + + @pytest.mark.asyncio + async def test_materialize_inbound_noop_without_attachments(self): + import langbot_plugin.api.entities.builtin.platform.message as platform_message + + service = self._service() + query = make_query() + query.message_chain = platform_message.MessageChain([platform_message.Plain(text='just text')]) + service.execute_tool = AsyncMock() + assert await service.materialize_inbound_attachments(query) == [] + service.execute_tool.assert_not_called() + + @pytest.mark.asyncio + async def test_collect_outbound_reads_and_clears(self): + service = self._service() + query = make_query() + + calls = [] + + async def fake_execute_tool(parameters, q): + calls.append(parameters['command']) + if 'os.walk' in parameters['command']: + return { + 'ok': True, + 'stdout': '[{"name": "out.png", "b64": "QUJD"}]', + 'stderr': '', + } + # the rm -rf cleanup call + return {'ok': True, 'stdout': '', 'stderr': ''} + + service.execute_tool = AsyncMock(side_effect=fake_execute_tool) + + attachments = await service.collect_outbound_attachments(query) + assert len(attachments) == 1 + assert attachments[0]['type'] == 'Image' + assert attachments[0]['name'] == 'out.png' + # cleanup (rm -rf) must have been issued after a successful collection + assert any('rm -rf' in c for c in calls) + + @pytest.mark.asyncio + async def test_collect_outbound_empty_still_clears(self): + # An empty collection MUST still clear the per-query outbox, so a later + # turn reusing the same query_id (the counter resets across restarts) + # cannot inherit stale files left from a prior run. + service = self._service() + query = make_query() + + calls = [] + + async def fake_execute_tool(parameters, q): + calls.append(parameters['command']) + if 'os.walk' in parameters['command']: + return {'ok': True, 'stdout': '[]', 'stderr': ''} + return {'ok': True, 'stdout': '', 'stderr': ''} + + service.execute_tool = AsyncMock(side_effect=fake_execute_tool) + assert await service.collect_outbound_attachments(query) == [] + # cleanup (rm -rf) is issued unconditionally now + assert any('rm -rf' in c for c in calls) + + @pytest.mark.asyncio + async def test_passthrough_noop_when_unavailable(self): + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + service._available = False + query = make_query() + assert await service.materialize_inbound_attachments(query) == [] + assert await service.collect_outbound_attachments(query) == [] + + +class TestAttachmentHostPath: + """Direct host-filesystem transfer path (bind-mounted workspace). + + When ``default_workspace`` is a real local dir, inbound/outbound bypass the + exec channel entirely (no ARG_MAX / stdout-truncation limits) and read/write + the bind-mounted host dir directly. + """ + + def _service_with_workspace(self, tmp_path): + ws = str(tmp_path / 'box' / 'default') + os.makedirs(ws, exist_ok=True) + app = make_app(Mock(), allowed_mount_roots=[str(tmp_path)], host_root=str(tmp_path / 'box')) + service = BoxService(app, client=Mock(spec=BoxRuntimeClient)) + service._available = True + # Force the default_workspace to our tmp dir so _host_query_dir resolves. + service.default_workspace = ws + return service, ws + + @pytest.mark.asyncio + async def test_inbound_writes_to_host_no_exec(self, tmp_path): + import base64 + + import langbot_plugin.api.entities.builtin.platform.message as platform_message + + service, ws = self._service_with_workspace(tmp_path) + # Big payload that would blow ARG_MAX on the exec path: + big = b'\x89PNG\r\n\x1a\n' + b'x' * (300 * 1024) + b64 = 'data:image/png;base64,' + base64.b64encode(big).decode() + query = make_query() + query.message_chain = platform_message.MessageChain([platform_message.Image(base64=b64)]) + # execute_tool must NOT be called on the host path. + service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path')) + + descriptors = await service.materialize_inbound_attachments(query) + assert len(descriptors) == 1 + d = descriptors[0] + assert d['type'] == 'Image' + assert d['size'] == len(big) + # File actually landed on the host workspace. + host_file = os.path.join(ws, 'inbox', str(query.query_id), d['name']) + assert os.path.isfile(host_file) + assert open(host_file, 'rb').read() == big + + @pytest.mark.asyncio + async def test_inbound_host_clears_stale_query_dir(self, tmp_path): + import base64 + + import langbot_plugin.api.entities.builtin.platform.message as platform_message + + service, ws = self._service_with_workspace(tmp_path) + # Seed a stale file under the same query_id (simulates webchat id reuse). + stale_dir = os.path.join(ws, 'inbox', '42') + os.makedirs(stale_dir, exist_ok=True) + open(os.path.join(stale_dir, 'image_1.png'), 'wb').write(b'STALE-OLD-IMAGE') + + new = b'\x89PNG\r\n\x1a\n NEW' + b64 = 'data:image/png;base64,' + base64.b64encode(new).decode() + query = make_query(query_id=42) + query.message_chain = platform_message.MessageChain([platform_message.Image(base64=b64)]) + service.execute_tool = AsyncMock() + descriptors = await service.materialize_inbound_attachments(query) + # The new write recreated the dir; the stale file is gone, new bytes present. + host_file = os.path.join(stale_dir, descriptors[0]['name']) + assert open(host_file, 'rb').read() == new + # No leftover content from the stale image. + assert b'STALE-OLD-IMAGE' not in open(host_file, 'rb').read() + + @pytest.mark.asyncio + async def test_outbound_reads_host_and_clears(self, tmp_path): + service, ws = self._service_with_workspace(tmp_path) + query = make_query() + outbox = os.path.join(ws, 'outbox', str(query.query_id)) + os.makedirs(outbox, exist_ok=True) + # A large file that would be truncated on the exec/stdout path: + big_png = b'\x89PNG\r\n\x1a\n' + b'y' * (400 * 1024) + open(os.path.join(outbox, 'result.png'), 'wb').write(big_png) + open(os.path.join(outbox, 'notes.txt'), 'wb').write(b'hello') + + service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path')) + attachments = await service.collect_outbound_attachments(query) + by_name = {a['name']: a for a in attachments} + assert by_name['result.png']['type'] == 'Image' + assert by_name['notes.txt']['type'] == 'File' + # Full image survived (no truncation). + import base64 + + raw = base64.b64decode(by_name['result.png']['base64'].split(',', 1)[-1]) + assert raw == big_png + # Outbox cleared after collection. + assert os.listdir(outbox) == [] + + @pytest.mark.asyncio + async def test_outbound_empty_clears_stale_host_dir(self, tmp_path): + # Reusing a query_id (counter resets on restart) must not re-send files + # a previous run left in the outbox: an empty collection still clears it. + service, ws = self._service_with_workspace(tmp_path) + query = make_query() + outbox = os.path.join(ws, 'outbox', str(query.query_id)) + os.makedirs(outbox, exist_ok=True) + # Stale file from a prior turn; the agent produced nothing this turn — + # but _read_outbox_host would still pick it up, so collection must drop + # it and then wipe the dir. Simulate "nothing produced this turn" by + # treating any present file as stale and asserting it is not re-sent + # across a second, genuinely-empty collection. + open(os.path.join(outbox, 'stale.png'), 'wb').write(b'\x89PNG\r\n\x1a\n old') + service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path')) + + # First collection drains + clears the dir. + first = await service.collect_outbound_attachments(query) + assert {a['name'] for a in first} == {'stale.png'} + assert os.listdir(outbox) == [] + + # Second collection (no new files) returns nothing and leaves a clean dir. + second = await service.collect_outbound_attachments(query) + assert second == [] + assert os.listdir(outbox) == [] + + @pytest.mark.asyncio + async def test_purge_attachment_dirs_wipes_host_owned_leftovers_on_init(self, tmp_path): + # Leftover inbox/outbox dirs from a previous process (same reset + # query_id counter) must be removed at startup. Host-owned files are + # cleared without any sandbox exec. + service, ws = self._service_with_workspace(tmp_path) + for sub in ('inbox', 'outbox'): + d = os.path.join(ws, sub, '0') + os.makedirs(d, exist_ok=True) + open(os.path.join(d, 'leftover.bin'), 'wb').write(b'from a previous process') + service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used for host-owned files')) + + await service._purge_attachment_dirs() + + assert not os.path.exists(os.path.join(ws, 'inbox')) + assert not os.path.exists(os.path.join(ws, 'outbox')) + # The workspace root itself survives. + assert os.path.isdir(ws) + + @pytest.mark.asyncio + async def test_purge_attachment_dirs_falls_back_to_exec_for_root_owned(self, tmp_path, monkeypatch): + # When the host delete cannot remove a dir (root-owned container output), + # purge must fall back to deleting from inside the sandbox via exec. + service, ws = self._service_with_workspace(tmp_path) + outbox = os.path.join(ws, 'outbox') + os.makedirs(os.path.join(outbox, '0'), exist_ok=True) + + # Simulate a host delete that cannot remove the root-owned outbox. + import shutil as _shutil + + real_rmtree = _shutil.rmtree + + def fake_rmtree(path, *a, **k): + if os.path.abspath(path) == os.path.abspath(outbox): + return # "permission denied" — silently leaves the dir + return real_rmtree(path, *a, **k) + + monkeypatch.setattr(_shutil, 'rmtree', fake_rmtree) + + executed = {} + spec_obj = object() + service.build_spec = Mock(return_value=spec_obj) + service.client.execute = AsyncMock(side_effect=lambda s: executed.setdefault('spec', s)) + + await service._purge_attachment_dirs() + + # build_spec was asked to rm the surviving outbox via exec. + cmd = service.build_spec.call_args.args[0]['cmd'] + assert 'rm -rf' in cmd and '/workspace/outbox' in cmd + assert '/workspace/inbox' not in cmd # inbox was host-deletable + service.client.execute.assert_awaited_once_with(spec_obj) + + @pytest.mark.asyncio + async def test_purge_attachment_dirs_noop_without_workspace(self): + # No bind-mounted workspace (E2B / remote): purge is a safe no-op. + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + service.default_workspace = None + # Must not raise. + await service._purge_attachment_dirs() diff --git a/tests/unit_tests/box/test_workspace.py b/tests/unit_tests/box/test_workspace.py new file mode 100644 index 000000000..e4620ad32 --- /dev/null +++ b/tests/unit_tests/box/test_workspace.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import os +import tempfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot.pkg.box.workspace import ( + BoxWorkspaceSession, + classify_python_workspace, + infer_workspace_host_path, + rewrite_mounted_path, + wrap_python_command_with_env, +) + + +def test_rewrite_mounted_path_translates_host_prefix(): + result = rewrite_mounted_path('/tmp/demo/project/app.py', '/tmp/demo/project') + assert result == '/workspace/app.py' + + +def test_infer_workspace_host_path_unwraps_virtualenv_bin_dir(): + with tempfile.TemporaryDirectory() as tmpdir: + project_root = os.path.join(tmpdir, 'project') + os.makedirs(os.path.join(project_root, '.venv', 'bin')) + python_bin = os.path.join(project_root, '.venv', 'bin', 'python') + script = os.path.join(project_root, 'server.py') + + with open(python_bin, 'w', encoding='utf-8') as handle: + handle.write('') + with open(script, 'w', encoding='utf-8') as handle: + handle.write('print("ok")\n') + + result = infer_workspace_host_path(python_bin, [script]) + + assert result == os.path.realpath(project_root) + + +def test_classify_python_workspace_detects_package_and_requirements(): + with tempfile.TemporaryDirectory() as tmpdir: + assert classify_python_workspace(tmpdir) is None + + with open(os.path.join(tmpdir, 'requirements.txt'), 'w', encoding='utf-8') as handle: + handle.write('requests\n') + assert classify_python_workspace(tmpdir) == 'requirements' + + with open(os.path.join(tmpdir, 'pyproject.toml'), 'w', encoding='utf-8') as handle: + handle.write('[project]\nname = "demo"\n') + assert classify_python_workspace(tmpdir) == 'package' + + +def test_wrap_python_command_with_env_contains_bootstrap_and_command(): + command = wrap_python_command_with_env('python script.py') + + assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command + assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command + assert 'kill -0 "$_LB_LOCK_OWNER"' in command + assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command + assert command.rstrip().endswith('python script.py') + + +@pytest.mark.asyncio +async def test_workspace_session_execute_for_query_uses_session_payload(): + box_service = SimpleNamespace(execute_spec_payload=AsyncMock(return_value={'ok': True})) + workspace = BoxWorkspaceSession( + box_service, + 'skill-person_123-demo', + host_path='/tmp/project', + host_path_mode='rw', + env={'FOO': 'bar'}, + ) + + query = SimpleNamespace(query_id='q1') + result = await workspace.execute_for_query(query, 'python run.py', workdir='/workspace', timeout_sec=30) + + assert result == {'ok': True} + payload = box_service.execute_spec_payload.await_args.args[0] + assert payload == { + 'session_id': 'skill-person_123-demo', + 'workdir': '/workspace', + 'env': {'FOO': 'bar'}, + 'persistent': False, + 'host_path': '/tmp/project', + 'host_path_mode': 'rw', + 'cmd': 'python run.py', + 'timeout_sec': 30, + } + + +@pytest.mark.asyncio +async def test_workspace_session_start_managed_process_rewrites_command_and_args(): + box_service = SimpleNamespace(start_managed_process=AsyncMock(return_value={'status': 'running'})) + workspace = BoxWorkspaceSession( + box_service, + 'mcp-u1', + host_path='/tmp/project', + host_path_mode='ro', + ) + + result = await workspace.start_managed_process( + '/tmp/project/.venv/bin/python', + ['/tmp/project/server.py', '--config', '/tmp/project/config.json'], + env={'TOKEN': '1'}, + ) + + assert result == {'status': 'running'} + session_id = box_service.start_managed_process.await_args.args[0] + payload = box_service.start_managed_process.await_args.args[1] + assert session_id == 'mcp-u1' + assert payload == { + 'command': 'python', + 'args': ['/workspace/server.py', '--config', '/workspace/config.json'], + 'env': {'TOKEN': '1'}, + 'cwd': '/workspace', + 'process_id': 'default', + } + + +def test_workspace_session_build_session_payload_keeps_generic_workspace_shape(): + workspace = BoxWorkspaceSession( + Mock(), + 'workspace-1', + host_path='/tmp/project', + host_path_mode='rw', + env={'FOO': 'bar'}, + network='on', + read_only_rootfs=False, + image='python:3.11', + cpus=1.0, + memory_mb=512, + pids_limit=128, + ) + + assert workspace.build_session_payload() == { + 'session_id': 'workspace-1', + 'workdir': '/workspace', + 'env': {'FOO': 'bar'}, + 'persistent': False, + 'network': 'on', + 'read_only_rootfs': False, + 'host_path': '/tmp/project', + 'host_path_mode': 'rw', + 'image': 'python:3.11', + 'cpus': 1.0, + 'memory_mb': 512, + 'pids_limit': 128, + } diff --git a/tests/unit_tests/command/__init__.py b/tests/unit_tests/command/__init__.py index 97081441c..99c57c405 100644 --- a/tests/unit_tests/command/__init__.py +++ b/tests/unit_tests/command/__init__.py @@ -1 +1 @@ -# Unit tests for command module \ No newline at end of file +# Unit tests for command module diff --git a/tests/unit_tests/command/test_cmdmgr.py b/tests/unit_tests/command/test_cmdmgr.py index 067eb7e43..ade27cf48 100644 --- a/tests/unit_tests/command/test_cmdmgr.py +++ b/tests/unit_tests/command/test_cmdmgr.py @@ -529,4 +529,4 @@ class TestEmptyAndEdgeInputs: # Should yield CommandNotFoundError (no such command registered) assert len(results) == 1 - assert results[0].error is not None \ No newline at end of file + assert results[0].error is not None diff --git a/tests/unit_tests/command/test_operator.py b/tests/unit_tests/command/test_operator.py index d099c7af8..a1d345292 100644 --- a/tests/unit_tests/command/test_operator.py +++ b/tests/unit_tests/command/test_operator.py @@ -197,6 +197,7 @@ class TestCommandOperatorBase: op = TestOperator(None) # Should not raise import asyncio + asyncio.get_event_loop().run_until_complete(op.initialize()) def test_execute_is_abstract(self): @@ -299,4 +300,4 @@ class TestMultipleOperators: yield None assert AdminOperator.lowest_privilege == 2 - assert SubOperator.lowest_privilege == 1 \ No newline at end of file + assert SubOperator.lowest_privilege == 1 diff --git a/tests/unit_tests/config/test_config_loader.py b/tests/unit_tests/config/test_config_loader.py index f228bf441..ec9942076 100644 --- a/tests/unit_tests/config/test_config_loader.py +++ b/tests/unit_tests/config/test_config_loader.py @@ -25,7 +25,7 @@ class TestYAMLConfigFile: @pytest.mark.asyncio async def test_valid_yaml_loads(self, tmp_path): """Valid YAML config should load correctly.""" - config_file = tmp_path / "test_config.yaml" + config_file = tmp_path / 'test_config.yaml' # Write valid YAML config_file.write_text(""" @@ -51,7 +51,7 @@ settings: @pytest.mark.asyncio async def test_invalid_yaml_raises_error(self, tmp_path): """Invalid YAML should raise clear error.""" - config_file = tmp_path / "invalid.yaml" + config_file = tmp_path / 'invalid.yaml' # Write invalid YAML (unclosed bracket) config_file.write_text(""" @@ -67,13 +67,13 @@ settings: template_data={'name': 'default'}, ) - with pytest.raises(Exception, match="Syntax error"): + with pytest.raises(Exception, match='Syntax error'): await yaml_file.load(completion=False) @pytest.mark.asyncio async def test_missing_config_creates_from_template(self, tmp_path): """Missing config file should be created from template.""" - config_file = tmp_path / "new_config.yaml" + config_file = tmp_path / 'new_config.yaml' # File doesn't exist yet assert not config_file.exists() @@ -92,7 +92,7 @@ settings: @pytest.mark.asyncio async def test_template_completion(self, tmp_path): """Config should be completed with template defaults.""" - config_file = tmp_path / "partial.yaml" + config_file = tmp_path / 'partial.yaml' # Write partial config missing some template keys config_file.write_text(""" @@ -115,7 +115,7 @@ name: custom_name @pytest.mark.asyncio async def test_yaml_save(self, tmp_path): """YAML config can be saved.""" - config_file = tmp_path / "save_test.yaml" + config_file = tmp_path / 'save_test.yaml' yaml_file = YAMLConfigFile( str(config_file), @@ -131,7 +131,7 @@ name: custom_name def test_yaml_save_sync(self, tmp_path): """YAML config can be saved synchronously.""" - config_file = tmp_path / "sync_save.yaml" + config_file = tmp_path / 'sync_save.yaml' yaml_file = YAMLConfigFile( str(config_file), @@ -151,14 +151,18 @@ class TestJSONConfigFile: @pytest.mark.asyncio async def test_valid_json_loads(self, tmp_path): """Valid JSON config should load correctly.""" - config_file = tmp_path / "test_config.json" + config_file = tmp_path / 'test_config.json' # Write valid JSON - config_file.write_text(json.dumps({ - 'name': 'json_app', - 'version': '1.0', - 'settings': {'debug': True, 'port': 8080}, - })) + config_file.write_text( + json.dumps( + { + 'name': 'json_app', + 'version': '1.0', + 'settings': {'debug': True, 'port': 8080}, + } + ) + ) json_file = JSONConfigFile( str(config_file), @@ -174,7 +178,7 @@ class TestJSONConfigFile: @pytest.mark.asyncio async def test_invalid_json_raises_error(self, tmp_path): """Invalid JSON should raise clear error.""" - config_file = tmp_path / "invalid.json" + config_file = tmp_path / 'invalid.json' # Write invalid JSON (missing closing brace) config_file.write_text('{"name": "test", "unclosed": ') @@ -184,13 +188,13 @@ class TestJSONConfigFile: template_data={'name': 'default'}, ) - with pytest.raises(Exception, match="Syntax error"): + with pytest.raises(Exception, match='Syntax error'): await json_file.load(completion=False) @pytest.mark.asyncio async def test_missing_json_creates_from_template(self, tmp_path): """Missing JSON file should be created from template.""" - config_file = tmp_path / "new_config.json" + config_file = tmp_path / 'new_config.json' json_file = JSONConfigFile( str(config_file), @@ -205,7 +209,7 @@ class TestJSONConfigFile: @pytest.mark.asyncio async def test_json_save(self, tmp_path): """JSON config can be saved.""" - config_file = tmp_path / "save_test.json" + config_file = tmp_path / 'save_test.json' json_file = JSONConfigFile( str(config_file), @@ -226,7 +230,7 @@ class TestConfigManager: @pytest.mark.asyncio async def test_config_manager_load(self, tmp_path): """ConfigManager loads config correctly.""" - config_file = tmp_path / "manager_test.yaml" + config_file = tmp_path / 'manager_test.yaml' config_file.write_text('name: managed_app\nversion: "1.0"\n') yaml_file = YAMLConfigFile( @@ -243,7 +247,7 @@ class TestConfigManager: @pytest.mark.asyncio async def test_config_manager_dump(self, tmp_path): """ConfigManager can dump config.""" - config_file = tmp_path / "dump_test.yaml" + config_file = tmp_path / 'dump_test.yaml' yaml_file = YAMLConfigFile( str(config_file), @@ -260,7 +264,7 @@ class TestConfigManager: def test_config_manager_dump_sync(self, tmp_path): """ConfigManager can dump config synchronously.""" - config_file = tmp_path / "sync_dump.yaml" + config_file = tmp_path / 'sync_dump.yaml' yaml_file = YAMLConfigFile( str(config_file), @@ -280,7 +284,7 @@ class TestConfigExists: def test_yaml_exists_true(self, tmp_path): """exists() returns True for existing file.""" - config_file = tmp_path / "exists.yaml" + config_file = tmp_path / 'exists.yaml' config_file.write_text('name: test') yaml_file = YAMLConfigFile(str(config_file), template_data={}) @@ -288,14 +292,14 @@ class TestConfigExists: def test_yaml_exists_false(self, tmp_path): """exists() returns False for missing file.""" - config_file = tmp_path / "missing.yaml" + config_file = tmp_path / 'missing.yaml' yaml_file = YAMLConfigFile(str(config_file), template_data={}) assert yaml_file.exists() is False def test_json_exists_true(self, tmp_path): """exists() returns True for existing JSON file.""" - config_file = tmp_path / "exists.json" + config_file = tmp_path / 'exists.json' config_file.write_text('{}') json_file = JSONConfigFile(str(config_file), template_data={}) @@ -303,7 +307,7 @@ class TestConfigExists: def test_json_exists_false(self, tmp_path): """exists() returns False for missing JSON file.""" - config_file = tmp_path / "missing.json" + config_file = tmp_path / 'missing.json' json_file = JSONConfigFile(str(config_file), template_data={}) - assert json_file.exists() is False \ No newline at end of file + assert json_file.exists() is False diff --git a/tests/unit_tests/core/__init__.py b/tests/unit_tests/core/__init__.py index c02aca956..1b8a74025 100644 --- a/tests/unit_tests/core/__init__.py +++ b/tests/unit_tests/core/__init__.py @@ -1 +1 @@ -"""Core module unit tests.""" \ No newline at end of file +"""Core module unit tests.""" diff --git a/tests/unit_tests/core/test_app_config_validation.py b/tests/unit_tests/core/test_app_config_validation.py index b90a3bd75..ddf9721da 100644 --- a/tests/unit_tests/core/test_app_config_validation.py +++ b/tests/unit_tests/core/test_app_config_validation.py @@ -4,6 +4,7 @@ Tests cover: - _get_positive_int_config() validation - _get_positive_float_config() validation """ + from __future__ import annotations from unittest.mock import Mock @@ -188,4 +189,4 @@ class TestGetPositiveFloatConfig: result = app._get_positive_float_config('not-a-number', default=1.5, name='test.config') assert result == 1.5 - mock_logger.warning.assert_called_once() \ No newline at end of file + mock_logger.warning.assert_called_once() diff --git a/tests/unit_tests/core/test_bootutils_deps.py b/tests/unit_tests/core/test_bootutils_deps.py index 35e928b94..c57baaf4b 100644 --- a/tests/unit_tests/core/test_bootutils_deps.py +++ b/tests/unit_tests/core/test_bootutils_deps.py @@ -27,6 +27,7 @@ class TestCheckDeps: from langbot.pkg.core.bootutils.deps import check_deps import asyncio + result = asyncio.get_event_loop().run_until_complete(check_deps()) assert result == [] @@ -46,6 +47,7 @@ class TestCheckDeps: from langbot.pkg.core.bootutils.deps import check_deps import asyncio + result = asyncio.get_event_loop().run_until_complete(check_deps()) assert 'requests' in result @@ -61,6 +63,7 @@ class TestCheckDeps: from langbot.pkg.core.bootutils.deps import check_deps, required_deps import asyncio + result = asyncio.get_event_loop().run_until_complete(check_deps()) # Should include all required_deps keys @@ -107,6 +110,7 @@ class TestPrecheckPluginDeps: with patch('os.path.exists', return_value=False): with patch('langbot.pkg.core.bootutils.deps.pkgmgr.install_requirements') as mock_install: import asyncio + asyncio.get_event_loop().run_until_complete(precheck_plugin_deps()) mock_install.assert_not_called() @@ -129,6 +133,7 @@ class TestPrecheckPluginDeps: with patch('os.listdir', side_effect=mock_listdir): with patch('langbot.pkg.core.bootutils.deps.pkgmgr.install_requirements') as mock_install: import asyncio + asyncio.get_event_loop().run_until_complete(precheck_plugin_deps()) mock_install.assert_called_once_with('plugins/plugin1/requirements.txt', extra_params=[]) diff --git a/tests/unit_tests/core/test_bootutils_log.py b/tests/unit_tests/core/test_bootutils_log.py new file mode 100644 index 000000000..fd01eb9c2 --- /dev/null +++ b/tests/unit_tests/core/test_bootutils_log.py @@ -0,0 +1,120 @@ +"""Tests for the daily-grouped rotating log file handler. + +Regression coverage for the bug where a long-running process names its log +file after the *start* day and keeps appending to it across midnight, so no +file ever appears for the current day. See +``langbot.pkg.core.bootutils.log.DailyGroupedRotatingFileHandler``. +""" + +from __future__ import annotations + +import logging +import os +import re + +import langbot.pkg.core.bootutils.log as logmod +from langbot.pkg.core.bootutils.log import DailyGroupedRotatingFileHandler + +# Mirror of the cleanup pattern in api/http/service/maintenance.py. +MAINTENANCE_LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$') + + +def _listing(directory): + return sorted(os.listdir(directory)) + + +def _make_logger(handler, name): + logger = logging.getLogger(name) + logger.setLevel(logging.INFO) + logger.handlers.clear() + logger.addHandler(handler) + logger.propagate = False + return logger + + +class TestDailyGroupedRotatingFileHandler: + def _patch_date(self, monkeypatch, box): + """Make the handler read its current date from ``box['date']``.""" + + def fake_strftime(fmt, t=None): + if fmt == '%Y-%m-%d': + return box['date'] + return '00:00:00' + + monkeypatch.setattr(logmod.time, 'strftime', fake_strftime) + + def test_initial_file_named_for_current_day(self, tmp_path, monkeypatch): + box = {'date': '2026-06-08'} + self._patch_date(monkeypatch, box) + + handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=10_000, backup_count=3) + logger = _make_logger(handler, 'lb_logtest_initial') + logger.info('hello') + handler.close() + + assert _listing(tmp_path) == ['langbot-2026-06-08.log'] + + def test_same_day_size_rotation_creates_numbered_backups(self, tmp_path, monkeypatch): + box = {'date': '2026-06-08'} + self._patch_date(monkeypatch, box) + + handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=200, backup_count=3) + logger = _make_logger(handler, 'lb_logtest_size') + for i in range(40): + logger.info('padding line to exceed maxBytes %d', i) + handler.close() + + files = _listing(tmp_path) + assert 'langbot-2026-06-08.log' in files + assert any(f.startswith('langbot-2026-06-08.log.') for f in files) + + def test_rolls_to_new_file_when_day_changes(self, tmp_path, monkeypatch): + box = {'date': '2026-06-08'} + self._patch_date(monkeypatch, box) + + handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=10_000, backup_count=3) + logger = _make_logger(handler, 'lb_logtest_midnight') + logger.info('day1 line') + + # Simulate crossing midnight within the same running process. + box['date'] = '2026-06-09' + logger.info('day2 line after midnight') + handler.close() + + files = _listing(tmp_path) + assert 'langbot-2026-06-08.log' in files + assert 'langbot-2026-06-09.log' in files + + day2 = (tmp_path / 'langbot-2026-06-09.log').read_text(encoding='utf-8') + assert 'day2 line after midnight' in day2 + assert 'day1 line' not in day2 + + def test_rollover_repeats_across_multiple_days(self, tmp_path, monkeypatch): + box = {'date': '2026-06-08'} + self._patch_date(monkeypatch, box) + + handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=10_000, backup_count=3) + logger = _make_logger(handler, 'lb_logtest_multiday') + for day in ('2026-06-08', '2026-06-09', '2026-06-10'): + box['date'] = day + logger.info('line for %s', day) + handler.close() + + files = _listing(tmp_path) + for day in ('2026-06-08', '2026-06-09', '2026-06-10'): + assert f'langbot-{day}.log' in files + + def test_all_filenames_match_maintenance_cleanup_pattern(self, tmp_path, monkeypatch): + box = {'date': '2026-06-08'} + self._patch_date(monkeypatch, box) + + handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=200, backup_count=3) + logger = _make_logger(handler, 'lb_logtest_pattern') + for i in range(40): + logger.info('padding line %d', i) + box['date'] = '2026-06-09' + logger.info('next day line') + handler.close() + + for name in _listing(tmp_path): + assert MAINTENANCE_LOG_FILE_PATTERN.match(name), name diff --git a/tests/unit_tests/core/test_load_config.py b/tests/unit_tests/core/test_load_config.py index 839a330f4..9a83f2fb6 100644 --- a/tests/unit_tests/core/test_load_config.py +++ b/tests/unit_tests/core/test_load_config.py @@ -7,6 +7,7 @@ Tests cover: - Dict type skipping - Missing key creation """ + from __future__ import annotations import os @@ -248,15 +249,8 @@ class TestApplyEnvOverridesToConfig: """Test multiple env vars applied in order.""" load_config = get_load_config_module() - cfg = { - 'system': {'name': 'default', 'enable': True}, - 'concurrency': {'pipeline': 5} - } - env = { - 'SYSTEM__NAME': 'custom', - 'SYSTEM__ENABLE': 'false', - 'CONCURRENCY__PIPELINE': '10' - } + cfg = {'system': {'name': 'default', 'enable': True}, 'concurrency': {'pipeline': 5}} + env = {'SYSTEM__NAME': 'custom', 'SYSTEM__ENABLE': 'false', 'CONCURRENCY__PIPELINE': '10'} with patch.dict(os.environ, env, clear=True): result = load_config._apply_env_overrides_to_config(cfg) @@ -287,4 +281,4 @@ class TestApplyEnvOverridesToConfig: with patch.dict(os.environ, env, clear=True): result = load_config._apply_env_overrides_to_config(cfg) - assert result['api']['extra_webhook_prefix'] == 'https://extra.example.com' \ No newline at end of file + assert result['api']['extra_webhook_prefix'] == 'https://extra.example.com' diff --git a/tests/unit_tests/core/test_migration.py b/tests/unit_tests/core/test_migration.py deleted file mode 100644 index 829cdbbdc..000000000 --- a/tests/unit_tests/core/test_migration.py +++ /dev/null @@ -1,238 +0,0 @@ -"""Tests for core migration registration and abstract classes.""" - -from __future__ import annotations - -from unittest.mock import MagicMock -import pytest - -from tests.utils.import_isolation import isolated_sys_modules - - -class TestMigrationClassDecorator: - """Tests for @migration_class decorator.""" - - def _make_migration_import_mocks(self): - """Create mocks for migration import.""" - return { - 'langbot.pkg.core.app': MagicMock(), - } - - def test_migration_class_registers_migration(self): - """@migration_class registers migration in preregistered_migrations.""" - mocks = self._make_migration_import_mocks() - - with isolated_sys_modules(mocks): - from langbot.pkg.core.migration import migration_class, preregistered_migrations - - # Clear for clean test - preregistered_migrations.clear() - - @migration_class('test-migration', 1) - class TestMigration: - pass - - assert len(preregistered_migrations) == 1 - assert preregistered_migrations[0] == TestMigration - - def test_migration_class_sets_name_attribute(self): - """@migration_class sets name attribute on class.""" - mocks = self._make_migration_import_mocks() - - with isolated_sys_modules(mocks): - from langbot.pkg.core.migration import migration_class - - @migration_class('test-migration', 1) - class TestMigration: - pass - - assert TestMigration.name == 'test-migration' - - def test_migration_class_sets_number_attribute(self): - """@migration_class sets number attribute on class.""" - mocks = self._make_migration_import_mocks() - - with isolated_sys_modules(mocks): - from langbot.pkg.core.migration import migration_class - - @migration_class('test-migration', 42) - class TestMigration: - pass - - assert TestMigration.number == 42 - - def test_migration_class_returns_original_class(self): - """@migration_class returns the original class.""" - mocks = self._make_migration_import_mocks() - - with isolated_sys_modules(mocks): - from langbot.pkg.core.migration import migration_class - - @migration_class('test', 1) - class TestMigration: - custom_attr = 'value' - - assert TestMigration.custom_attr == 'value' - - def test_migration_class_multiple_migrations(self): - """Multiple migrations can be registered.""" - mocks = self._make_migration_import_mocks() - - with isolated_sys_modules(mocks): - from langbot.pkg.core.migration import migration_class, preregistered_migrations - - preregistered_migrations.clear() - - @migration_class('migration1', 1) - class Migration1: - pass - - @migration_class('migration2', 2) - class Migration2: - pass - - assert len(preregistered_migrations) == 2 - assert preregistered_migrations[0] == Migration1 - assert preregistered_migrations[1] == Migration2 - - -class TestMigrationAbstractClass: - """Tests for Migration abstract class.""" - - def _make_migration_import_mocks(self): - return {'langbot.pkg.core.app': MagicMock()} - - def test_migration_is_abstract(self): - """Migration is abstract and cannot be instantiated directly.""" - mocks = self._make_migration_import_mocks() - - with isolated_sys_modules(mocks): - from langbot.pkg.core.migration import Migration - - with pytest.raises(TypeError): - Migration(MagicMock()) - - def test_migration_requires_need_migrate_method(self): - """Subclass must implement need_migrate method.""" - mocks = self._make_migration_import_mocks() - - with isolated_sys_modules(mocks): - from langbot.pkg.core.migration import Migration - - class IncompleteMigration(Migration): - async def run(self): - pass - - with pytest.raises(TypeError): - IncompleteMigration(MagicMock()) - - def test_migration_requires_run_method(self): - """Subclass must implement run method.""" - mocks = self._make_migration_import_mocks() - - with isolated_sys_modules(mocks): - from langbot.pkg.core.migration import Migration - - class IncompleteMigration(Migration): - async def need_migrate(self) -> bool: - return False - - with pytest.raises(TypeError): - IncompleteMigration(MagicMock()) - - def test_migration_subclass_works(self): - """Complete subclass can be instantiated.""" - mocks = self._make_migration_import_mocks() - - with isolated_sys_modules(mocks): - from langbot.pkg.core.migration import Migration - - class CompleteMigration(Migration): - async def need_migrate(self) -> bool: - return True - - async def run(self): - pass - - mock_ap = MagicMock() - migration = CompleteMigration(mock_ap) - assert migration.ap == mock_ap - - def test_migration_stores_app_reference(self): - """Migration stores ap reference in __init__.""" - mocks = self._make_migration_import_mocks() - - with isolated_sys_modules(mocks): - from langbot.pkg.core.migration import Migration - - class TestMigration(Migration): - async def need_migrate(self) -> bool: - return False - - async def run(self): - pass - - mock_ap = MagicMock() - migration = TestMigration(mock_ap) - assert migration.ap is mock_ap - - @pytest.mark.asyncio - async def test_migration_need_migrate_returns_bool(self): - """need_migrate must return bool.""" - mocks = self._make_migration_import_mocks() - - with isolated_sys_modules(mocks): - from langbot.pkg.core.migration import Migration - - class TestMigration(Migration): - async def need_migrate(self) -> bool: - return True - - async def run(self): - pass - - migration = TestMigration(MagicMock()) - result = await migration.need_migrate() - assert isinstance(result, bool) - assert result == True - - -class TestPreregisteredMigrations: - """Tests for preregistered_migrations global registry.""" - - def _make_migration_import_mocks(self): - return {'langbot.pkg.core.app': MagicMock()} - - def test_preregistered_migrations_is_list(self): - """preregistered_migrations is a list.""" - mocks = self._make_migration_import_mocks() - - with isolated_sys_modules(mocks): - from langbot.pkg.core.migration import preregistered_migrations - - assert isinstance(preregistered_migrations, list) - - def test_preregistered_migrations_order(self): - """Migrations are registered in order of decoration.""" - mocks = self._make_migration_import_mocks() - - with isolated_sys_modules(mocks): - from langbot.pkg.core.migration import migration_class, preregistered_migrations - - preregistered_migrations.clear() - - @migration_class('first', 1) - class First: - pass - - @migration_class('second', 2) - class Second: - pass - - @migration_class('third', 3) - class Third: - pass - - # Order should match decoration order - assert preregistered_migrations[0].number == 1 - assert preregistered_migrations[1].number == 2 - assert preregistered_migrations[2].number == 3 \ No newline at end of file diff --git a/tests/unit_tests/core/test_stage.py b/tests/unit_tests/core/test_stage.py index e09cbd310..e9b29e76b 100644 --- a/tests/unit_tests/core/test_stage.py +++ b/tests/unit_tests/core/test_stage.py @@ -175,4 +175,4 @@ class TestPreregisteredStages: pass for key in preregistered_stages: - assert isinstance(key, str) \ No newline at end of file + assert isinstance(key, str) diff --git a/tests/unit_tests/core/test_taskmgr.py b/tests/unit_tests/core/test_taskmgr.py index ca05724da..6c0d9828f 100644 --- a/tests/unit_tests/core/test_taskmgr.py +++ b/tests/unit_tests/core/test_taskmgr.py @@ -7,6 +7,7 @@ Tests cover: Note: Uses import_isolation to break circular import chains. """ + from __future__ import annotations import pytest @@ -19,15 +20,17 @@ from typing import Generator class MockLifecycleControlScopeEnum: """Mock enum value for LifecycleControlScope with .value attribute.""" + def __init__(self, value: str): self.value = value def __repr__(self): - return f"LifecycleControlScope.{self.value.upper()}" + return f'LifecycleControlScope.{self.value.upper()}' class MockLifecycleControlScope: """Mock enum for LifecycleControlScope.""" + APPLICATION = MockLifecycleControlScopeEnum('application') PLATFORM = MockLifecycleControlScopeEnum('platform') PIPELINE = MockLifecycleControlScopeEnum('pipeline') @@ -40,17 +43,17 @@ def isolated_taskmgr_import() -> Generator[None, None, None]: # Mock modules that cause circular imports mock_entities = MagicMock() mock_entities.LifecycleControlScope = MockLifecycleControlScope - + mock_app = MagicMock() - + mock_importutil = MagicMock() mock_importutil.import_modules_in_pkg = lambda pkg: None mock_importutil.import_modules_in_pkgs = lambda pkgs: None - + mock_http_controller = MagicMock() - + mock_rag_mgr = MagicMock() - + mocks = { 'langbot.pkg.core.entities': mock_entities, 'langbot.pkg.core.app': mock_app, @@ -58,26 +61,26 @@ def isolated_taskmgr_import() -> Generator[None, None, None]: 'langbot.pkg.rag.knowledge.kbmgr': mock_rag_mgr, 'langbot.pkg.utils.importutil': mock_importutil, } - + # Save original state saved = {} for name in mocks: if name in sys.modules: saved[name] = sys.modules[name] - + # Clear taskmgr to force re-import taskmgr_name = 'langbot.pkg.core.taskmgr' if taskmgr_name in sys.modules: saved[taskmgr_name] = sys.modules[taskmgr_name] - + try: # Apply mocks for name, module in mocks.items(): sys.modules[name] = module - + # Clear taskmgr sys.modules.pop(taskmgr_name, None) - + yield finally: # Restore @@ -86,7 +89,7 @@ def isolated_taskmgr_import() -> Generator[None, None, None]: sys.modules[name] = saved[name] else: sys.modules.pop(name, None) - + if taskmgr_name in saved: sys.modules[taskmgr_name] = saved[taskmgr_name] else: @@ -97,6 +100,7 @@ def get_taskmgr_classes(): """Get TaskContext, TaskWrapper, AsyncTaskManager classes.""" with isolated_taskmgr_import(): from langbot.pkg.core.taskmgr import TaskContext, TaskWrapper, AsyncTaskManager + return TaskContext, TaskWrapper, AsyncTaskManager @@ -194,9 +198,10 @@ class TestTaskContext: """Test TaskContext.placeholder() returns singleton.""" with isolated_taskmgr_import(): from langbot.pkg.core.taskmgr import TaskContext - + # Reset global placeholder import langbot.pkg.core.taskmgr as taskmgr_module + taskmgr_module.placeholder_context = None ctx1 = TaskContext.placeholder() @@ -269,7 +274,8 @@ class TestTaskWrapper: return 'result' wrapper = TaskWrapper( - mock_app, immediate_coro(), + mock_app, + immediate_coro(), name='test_task', label='Test Task', ) @@ -414,7 +420,7 @@ class TestAsyncTaskManager: async def test_cancel_by_scope(self): """Test cancel_by_scope cancels matching tasks.""" _, _, AsyncTaskManager = get_taskmgr_classes() - + mock_app = create_mock_app() manager = AsyncTaskManager(mock_app) @@ -422,16 +428,10 @@ class TestAsyncTaskManager: await asyncio.sleep(10) # Create task with APPLICATION scope - w1 = manager.create_task( - long_coro(), - scopes=[MockLifecycleControlScope.APPLICATION] - ) + w1 = manager.create_task(long_coro(), scopes=[MockLifecycleControlScope.APPLICATION]) # Create task with different scope - w2 = manager.create_task( - long_coro(), - scopes=[MockLifecycleControlScope.PIPELINE] - ) + w2 = manager.create_task(long_coro(), scopes=[MockLifecycleControlScope.PIPELINE]) manager.cancel_by_scope(MockLifecycleControlScope.APPLICATION) diff --git a/tests/unit_tests/discover/test_engine.py b/tests/unit_tests/discover/test_engine.py index 63ce82d84..0f4efc9c9 100644 --- a/tests/unit_tests/discover/test_engine.py +++ b/tests/unit_tests/discover/test_engine.py @@ -15,68 +15,68 @@ class TestI18nString: def test_create_with_english_only(self): """Create I18nString with only English.""" - i18n = I18nString(en_US="Hello") + i18n = I18nString(en_US='Hello') - assert i18n.en_US == "Hello" + assert i18n.en_US == 'Hello' assert i18n.zh_Hans is None def test_create_with_multiple_languages(self): """Create I18nString with multiple languages.""" i18n = I18nString( - en_US="Hello", - zh_Hans="你好", - zh_Hant="你好", - ja_JP="こんにちは", + en_US='Hello', + zh_Hans='你好', + zh_Hant='你好', + ja_JP='こんにちは', ) - assert i18n.en_US == "Hello" - assert i18n.zh_Hans == "你好" - assert i18n.zh_Hant == "你好" - assert i18n.ja_JP == "こんにちは" + assert i18n.en_US == 'Hello' + assert i18n.zh_Hans == '你好' + assert i18n.zh_Hant == '你好' + assert i18n.ja_JP == 'こんにちは' def test_to_dict_with_english_only(self): """to_dict returns only non-None fields.""" - i18n = I18nString(en_US="Hello") + i18n = I18nString(en_US='Hello') result = i18n.to_dict() - assert result == {"en_US": "Hello"} + assert result == {'en_US': 'Hello'} def test_to_dict_with_multiple_languages(self): """to_dict returns all non-None fields.""" i18n = I18nString( - en_US="Hello", - zh_Hans="你好", + en_US='Hello', + zh_Hans='你好', ) result = i18n.to_dict() - assert result == {"en_US": "Hello", "zh_Hans": "你好"} + assert result == {'en_US': 'Hello', 'zh_Hans': '你好'} def test_to_dict_excludes_none(self): """to_dict excludes None values.""" i18n = I18nString( - en_US="Hello", + en_US='Hello', zh_Hans=None, - ja_JP="こんにちは", + ja_JP='こんにちは', ) result = i18n.to_dict() - assert "zh_Hans" not in result - assert "en_US" in result - assert "ja_JP" in result + assert 'zh_Hans' not in result + assert 'en_US' in result + assert 'ja_JP' in result def test_to_dict_all_languages(self): """to_dict with all supported languages.""" i18n = I18nString( - en_US="Hello", - zh_Hans="你好", - zh_Hant="你好", - ja_JP="こんにちは", - th_TH="สวัสดี", - vi_VN="Xin chào", - es_ES="Hola", + en_US='Hello', + zh_Hans='你好', + zh_Hant='你好', + ja_JP='こんにちは', + th_TH='สวัสดี', + vi_VN='Xin chào', + es_ES='Hola', ) result = i18n.to_dict() @@ -92,30 +92,30 @@ class TestMetadata: from langbot.pkg.discover.engine import I18nString metadata = Metadata( - name="test-component", - label=I18nString(en_US="Test Component"), + name='test-component', + label=I18nString(en_US='Test Component'), ) - assert metadata.name == "test-component" - assert metadata.label.en_US == "Test Component" + assert metadata.name == 'test-component' + assert metadata.label.en_US == 'Test Component' def test_create_with_all_fields(self): """Create Metadata with all optional fields.""" from langbot.pkg.discover.engine import I18nString metadata = Metadata( - name="test-component", - label=I18nString(en_US="Test"), - description=I18nString(en_US="A test component"), - version="1.0.0", - icon="test-icon", - author="Test Author", - repository="https://github.com/test/repo", + name='test-component', + label=I18nString(en_US='Test'), + description=I18nString(en_US='A test component'), + version='1.0.0', + icon='test-icon', + author='Test Author', + repository='https://github.com/test/repo', ) - assert metadata.version == "1.0.0" - assert metadata.icon == "test-icon" - assert metadata.author == "Test Author" + assert metadata.version == '1.0.0' + assert metadata.icon == 'test-icon' + assert metadata.author == 'Test Author' class TestComponentManifest: diff --git a/tests/unit_tests/persistence/test_database_decorator.py b/tests/unit_tests/persistence/test_database_decorator.py index 222cd3a3c..d72d48e0b 100644 --- a/tests/unit_tests/persistence/test_database_decorator.py +++ b/tests/unit_tests/persistence/test_database_decorator.py @@ -7,6 +7,7 @@ Tests cover: Note: Uses import isolation to break circular import chains. """ + from __future__ import annotations import sys @@ -86,6 +87,7 @@ def get_database_module(): """Get database module with import isolation.""" with isolated_database_import(): from langbot.pkg.persistence import database + return database @@ -198,4 +200,4 @@ class TestManagerClassDecorator: # Create instance to test method (with mock app) mock_app = Mock() instance = ManagerWithMethods(mock_app) - assert instance.custom_method() == 'test_value' \ No newline at end of file + assert instance.custom_method() == 'test_value' diff --git a/tests/unit_tests/persistence/test_mgr_methods.py b/tests/unit_tests/persistence/test_mgr_methods.py index 2145f84eb..0c75cf2d4 100644 --- a/tests/unit_tests/persistence/test_mgr_methods.py +++ b/tests/unit_tests/persistence/test_mgr_methods.py @@ -4,6 +4,7 @@ Tests cover: - execute_async() with mock database - get_db_engine() with mock database manager """ + from __future__ import annotations import pytest @@ -85,7 +86,7 @@ class TestExecuteAsync: mock_db.get_engine = Mock(return_value=mock_engine) mgr.db = mock_db - result = await mgr.execute_async(sqlalchemy.text("SELECT 1")) + result = await mgr.execute_async(sqlalchemy.text('SELECT 1')) # Verify result is the same object returned by execute assert result is mock_result @@ -152,4 +153,4 @@ class TestSerializeModelEdgeCases: result = mgr.serialize_model(SimpleModel, instance, masked_columns=['id', 'name']) # Result should be empty dict when all columns masked - assert result == {} \ No newline at end of file + assert result == {} diff --git a/tests/unit_tests/persistence/test_serialize_model.py b/tests/unit_tests/persistence/test_serialize_model.py index 199c3a8f2..bbab59c67 100644 --- a/tests/unit_tests/persistence/test_serialize_model.py +++ b/tests/unit_tests/persistence/test_serialize_model.py @@ -5,6 +5,7 @@ Tests cover: - datetime conversion to isoformat - masked_columns exclusion """ + from __future__ import annotations import datetime diff --git a/tests/unit_tests/pipeline/conftest.py b/tests/unit_tests/pipeline/conftest.py index a10e0aba1..ce8ee7eb0 100644 --- a/tests/unit_tests/pipeline/conftest.py +++ b/tests/unit_tests/pipeline/conftest.py @@ -12,6 +12,12 @@ from __future__ import annotations import pytest from unittest.mock import AsyncMock, Mock +# Preload pipelinemgr so the pipeline.stage module is fully initialised before +# any individual stage test (e.g. preproc, longtext) tries to import it. Without +# this, running a stage test in isolation triggers a circular-import error: +# stage.py → core.app → pipelinemgr → stage.stage_class (not yet bound). +import langbot.pkg.pipeline.pipelinemgr # noqa: F401 + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events @@ -34,6 +40,9 @@ class MockApplication: self.query_pool = self._create_mock_query_pool() self.instance_config = self._create_mock_instance_config() self.task_mgr = self._create_mock_task_manager() + # Skill manager is optional; PreProcessor only touches it for the + # local-agent runner. None keeps the skill-binding branch inert. + self.skill_mgr = None def _create_mock_logger(self): logger = Mock() diff --git a/tests/unit_tests/pipeline/test_aggregator.py b/tests/unit_tests/pipeline/test_aggregator.py index 97ac35c38..9eab7615f 100644 --- a/tests/unit_tests/pipeline/test_aggregator.py +++ b/tests/unit_tests/pipeline/test_aggregator.py @@ -49,7 +49,7 @@ class TestPendingMessage: """PendingMessage should be created with correct fields.""" aggregator = get_aggregator_module() - chain = text_chain("hello") + chain = text_chain('hello') event = friend_message_event(chain) adapter = mock_adapter() @@ -88,7 +88,7 @@ class TestSessionBuffer: """SessionBuffer should accept initial messages.""" aggregator = get_aggregator_module() - chain = text_chain("hello") + chain = text_chain('hello') event = friend_message_event(chain) adapter = mock_adapter() @@ -309,7 +309,7 @@ class TestMessageAggregatorAddMessage: app = make_aggregator_app() agg = aggregator.MessageAggregator(app) - chain = text_chain("hello") + chain = text_chain('hello') event = friend_message_event(chain) adapter = mock_adapter() @@ -348,7 +348,7 @@ class TestMessageAggregatorAddMessage: agg = aggregator.MessageAggregator(app) - chain = text_chain("hello") + chain = text_chain('hello') event = friend_message_event(chain) adapter = mock_adapter() @@ -387,7 +387,7 @@ class TestMessageAggregatorAddMessage: agg = aggregator.MessageAggregator(app) - chain = text_chain("hello") + chain = text_chain('hello') event = friend_message_event(chain) adapter = mock_adapter() @@ -419,7 +419,7 @@ class TestMessageAggregatorMerge: app = make_aggregator_app() agg = aggregator.MessageAggregator(app) - chain = text_chain("hello") + chain = text_chain('hello') event = friend_message_event(chain) adapter = mock_adapter() @@ -445,8 +445,8 @@ class TestMessageAggregatorMerge: app = make_aggregator_app() agg = aggregator.MessageAggregator(app) - chain1 = text_chain("hello") - chain2 = text_chain("world") + chain1 = text_chain('hello') + chain2 = text_chain('world') event = friend_message_event(chain1) adapter = mock_adapter() @@ -476,8 +476,8 @@ class TestMessageAggregatorMerge: # Should contain both messages with separator merged_str = str(merged.message_chain) - assert "hello" in merged_str - assert "world" in merged_str + assert 'hello' in merged_str + assert 'world' in merged_str def test_merge_messages_preserves_routed_by_rule_if_any_input_matches(self): """Merged PendingMessage should keep routed_by_rule when any input was rule-routed.""" @@ -486,8 +486,8 @@ class TestMessageAggregatorMerge: app = make_aggregator_app() agg = aggregator.MessageAggregator(app) - chain1 = text_chain("first") - chain2 = text_chain("second") + chain1 = text_chain('first') + chain2 = text_chain('second') event = friend_message_event(chain1) adapter = mock_adapter() @@ -545,7 +545,7 @@ class TestMessageAggregatorFlush: app = make_aggregator_app() agg = aggregator.MessageAggregator(app) - chain = text_chain("hello") + chain = text_chain('hello') event = friend_message_event(chain) adapter = mock_adapter() @@ -597,7 +597,7 @@ class TestMessageAggregatorFlushAll: app = make_aggregator_app() agg = aggregator.MessageAggregator(app) - chain = text_chain("hello") + chain = text_chain('hello') event = friend_message_event(chain) adapter = mock_adapter() diff --git a/tests/unit_tests/pipeline/test_chat_handler.py b/tests/unit_tests/pipeline/test_chat_handler.py index 097ef2b4a..c8a923d78 100644 --- a/tests/unit_tests/pipeline/test_chat_handler.py +++ b/tests/unit_tests/pipeline/test_chat_handler.py @@ -15,6 +15,7 @@ from tests.factories import FakeApp # ============== FIXTURE USING IMPORT ISOLATION UTILITY ============== + @pytest.fixture(scope='module') def mock_circular_import_chain(): """ @@ -36,9 +37,11 @@ def mock_circular_import_chain(): # Create a default runner that yields a simple response class DefaultRunner: name = 'local-agent' + def __init__(self, app, config): self.app = app self.config = config + async def run(self, query): yield Message(role='assistant', content='fake response') @@ -70,9 +73,12 @@ def mock_event_ctx(): @pytest.fixture def set_runner(): """Factory fixture to set a custom runner for tests.""" + def _set_runner(runner_class): import sys + sys.modules['langbot.pkg.provider.runner'].preregistered_runners = [runner_class] + return _set_runner @@ -87,6 +93,7 @@ def get_chat_handler(): global _chat_handler_module if _chat_handler_module is None: from importlib import import_module + _chat_handler_module = import_module('langbot.pkg.pipeline.process.handlers.chat') return _chat_handler_module @@ -96,12 +103,14 @@ def get_entities(): global _entities_module if _entities_module is None: from importlib import import_module + _entities_module = import_module('langbot.pkg.pipeline.entities') return _entities_module # ============== REAL ChatMessageHandler Tests ============== + @pytest.mark.usefixtures('mock_circular_import_chain') class TestChatMessageHandlerReal: """Tests for real ChatMessageHandler class.""" @@ -188,9 +197,11 @@ class TestChatMessageHandlerReal: class QuickRunner: name = 'local-agent' + def __init__(self, app, config): self.app = app self.config = config + async def run(self, query): yield Message(role='assistant', content='ok') @@ -222,9 +233,11 @@ class TestChatMessageHandlerReal: class SingleRunner: name = 'local-agent' + def __init__(self, app, config): self.app = app self.config = config + async def run(self, query): yield Message(role='assistant', content='response') @@ -262,9 +275,11 @@ class TestChatHandlerStreaming: class StreamRunner: name = 'local-agent' + def __init__(self, app, config): self.app = app self.config = config + async def run(self, query): yield MessageChunk(role='assistant', content='Hello', is_final=False) yield MessageChunk(role='assistant', content=' World', is_final=True) @@ -303,14 +318,19 @@ class TestChatHandlerExceptions: query.pipeline_config = { 'output': {'misc': {'exception-handling': 'show-hint', 'failure-hint': 'Request failed.'}}, - 'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}}, + 'ai': { + 'runner': {'runner': 'local-agent'}, + 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}, + }, } class FailingRunner: name = 'local-agent' + def __init__(self, app, config): self.app = app self.config = config + async def run(self, query): raise ValueError('API error') yield @@ -346,14 +366,19 @@ class TestChatHandlerExceptions: query.pipeline_config = { 'output': {'misc': {'exception-handling': 'show-error'}}, - 'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}}, + 'ai': { + 'runner': {'runner': 'local-agent'}, + 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}, + }, } class ErrorRunner: name = 'local-agent' + def __init__(self, app, config): self.app = app self.config = config + async def run(self, query): raise ValueError('Custom error') yield @@ -386,14 +411,19 @@ class TestChatHandlerExceptions: query.pipeline_config = { 'output': {'misc': {'exception-handling': 'hide'}}, - 'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}}, + 'ai': { + 'runner': {'runner': 'local-agent'}, + 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}, + }, } class HideErrorRunner: name = 'local-agent' + def __init__(self, app, config): self.app = app self.config = config + async def run(self, query): raise RuntimeError('hidden') yield @@ -433,4 +463,4 @@ class TestChatHandlerHelper: chat = get_chat_handler() handler = chat.ChatMessageHandler(fake_app) result = handler.cut_str('first line\nsecond line') - assert '...' in result \ No newline at end of file + assert '...' in result diff --git a/tests/unit_tests/pipeline/test_chat_handler_logging.py b/tests/unit_tests/pipeline/test_chat_handler_logging.py new file mode 100644 index 000000000..6ae85558f --- /dev/null +++ b/tests/unit_tests/pipeline/test_chat_handler_logging.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from unittest.mock import Mock + +import pytest +import langbot_plugin.api.entities.builtin.provider.message as provider_message + +# TODO: unskip once the handler ↔ app circular import is resolved +pytest.skip( + 'circular import in handler ↔ app; will be unblocked once resolved', + allow_module_level=True, +) + +from langbot.pkg.pipeline.process.handler import MessageHandler # noqa: E402 + + +class _StubHandler(MessageHandler): + async def handle(self, query): + raise NotImplementedError + + +handler = _StubHandler(ap=Mock()) + + +def test_chat_handler_formats_tool_call_request_log(): + result = provider_message.Message( + role='assistant', + content='', + tool_calls=[ + provider_message.ToolCall( + id='call-1', + type='function', + function=provider_message.FunctionCall(name='exec', arguments='{}'), + ) + ], + ) + + summary = handler.format_result_log(result) + + assert summary == 'assistant: requested tools: exec' + + +def test_chat_handler_formats_tool_result_log(): + result = provider_message.Message( + role='tool', + content='{"status":"completed","exit_code":0,"backend":"podman","stdout":"42\\n"}', + tool_call_id='call-1', + ) + + summary = handler.format_result_log(result) + + # Tool results use generic cut_str truncation + assert summary is not None + assert summary.startswith('tool: {"status":"com') + assert summary.endswith('...') + + +def test_chat_handler_formats_tool_error_log(): + result = provider_message.MessageChunk( + role='tool', + content='err: host_path must point to an existing directory on the host', + tool_call_id='call-1', + is_final=True, + ) + + summary = handler.format_result_log(result) + + assert summary is not None + assert summary.startswith('tool error: err: host_path must') + assert summary.endswith('...') + + +def test_chat_handler_skips_empty_assistant_log(): + result = provider_message.Message(role='assistant', content='') + + summary = handler.format_result_log(result) + + assert summary is None diff --git a/tests/unit_tests/pipeline/test_cntfilter.py b/tests/unit_tests/pipeline/test_cntfilter.py index 1d29d1797..f0e46b41a 100644 --- a/tests/unit_tests/pipeline/test_cntfilter.py +++ b/tests/unit_tests/pipeline/test_cntfilter.py @@ -67,7 +67,11 @@ def make_pipeline_config(**overrides): for key, value in overrides.items(): if key in base_config and isinstance(base_config[key], dict) and isinstance(value, dict): for sub_key, sub_value in value.items(): - if sub_key in base_config[key] and isinstance(base_config[key][sub_key], dict) and isinstance(sub_value, dict): + if ( + sub_key in base_config[key] + and isinstance(base_config[key][sub_key], dict) + and isinstance(sub_value, dict) + ): base_config[key][sub_key].update(sub_value) else: base_config[key][sub_key] = sub_value @@ -141,7 +145,7 @@ class TestPreContentFilter: await stage.initialize(pipeline_config) - query = text_query("hello world") + query = text_query('hello world') query.pipeline_config = pipeline_config result = await stage.process(query, 'PreContentFilterStage') @@ -163,7 +167,7 @@ class TestPreContentFilter: await stage.initialize(pipeline_config) # Empty message chain - query = text_query("") + query = text_query('') query.message_chain = platform_message.MessageChain([]) query.pipeline_config = pipeline_config @@ -185,7 +189,7 @@ class TestPreContentFilter: await stage.initialize(pipeline_config) - query = text_query(" ") # Only whitespace + query = text_query(' ') # Only whitespace query.pipeline_config = pipeline_config result = await stage.process(query, 'PreContentFilterStage') @@ -234,7 +238,7 @@ class TestPreContentFilter: await stage.initialize(pipeline_config) - query = text_query("hello world") + query = text_query('hello world') query.pipeline_config = pipeline_config result = await stage.process(query, 'PreContentFilterStage') @@ -266,7 +270,7 @@ class TestContentIgnoreFilter: await stage.initialize(pipeline_config) - query = text_query("/help me") + query = text_query('/help me') query.pipeline_config = pipeline_config result = await stage.process(query, 'PreContentFilterStage') @@ -294,7 +298,7 @@ class TestContentIgnoreFilter: await stage.initialize(pipeline_config) - query = text_query("http://example.com") + query = text_query('http://example.com') query.pipeline_config = pipeline_config result = await stage.process(query, 'PreContentFilterStage') @@ -322,7 +326,7 @@ class TestContentIgnoreFilter: await stage.initialize(pipeline_config) - query = text_query("normal message") + query = text_query('normal message') query.pipeline_config = pipeline_config result = await stage.process(query, 'PreContentFilterStage') @@ -343,7 +347,7 @@ class TestContentIgnoreFilter: await stage.initialize(pipeline_config) - query = text_query("/help me") + query = text_query('/help me') query.pipeline_config = pipeline_config result = await stage.process(query, 'PreContentFilterStage') @@ -368,12 +372,10 @@ class TestPostContentFilter: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config # Add a response message - query.resp_messages = [ - provider_message.Message(role='assistant', content='Hello back!') - ] + query.resp_messages = [provider_message.Message(role='assistant', content='Hello back!')] result = await stage.process(query, 'PostContentFilterStage') @@ -398,11 +400,9 @@ class TestPostContentFilter: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config - query.resp_messages = [ - provider_message.Message(role='assistant', content='Response') - ] + query.resp_messages = [provider_message.Message(role='assistant', content='Response')] result = await stage.process(query, 'PostContentFilterStage') @@ -422,7 +422,7 @@ class TestPostContentFilter: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config # Non-string content - use model_construct to bypass validation # The actual content type could be a list of ContentElement objects @@ -450,11 +450,9 @@ class TestPostContentFilter: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config - query.resp_messages = [ - provider_message.Message(role='assistant', content='') - ] + query.resp_messages = [provider_message.Message(role='assistant', content='')] result = await stage.process(query, 'PostContentFilterStage') @@ -476,7 +474,7 @@ class TestContentFilterStageInvalidName: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config with pytest.raises(ValueError, match='未知的 stage_inst_name'): @@ -506,7 +504,7 @@ class TestContentIgnoreFilterDirect: await stage.initialize(pipeline_config) - query = text_query("normal message without prefix") + query = text_query('normal message without prefix') query.pipeline_config = pipeline_config result = await stage.process(query, 'PreContentFilterStage') diff --git a/tests/unit_tests/pipeline/test_command_handler.py b/tests/unit_tests/pipeline/test_command_handler.py index 5006d2487..00bd5b681 100644 --- a/tests/unit_tests/pipeline/test_command_handler.py +++ b/tests/unit_tests/pipeline/test_command_handler.py @@ -15,6 +15,7 @@ from tests.factories import FakeApp, command_query # ============== FIXTURE USING IMPORT ISOLATION UTILITY ============== + @pytest.fixture(scope='module') def mock_circular_import_chain(): """ @@ -56,6 +57,7 @@ def mock_event_ctx(): @pytest.fixture def mock_execute_factory(): """Factory fixture to create mock cmd_mgr.execute generators.""" + def _create_execute( text: str | None = 'ok', error: str | None = None, @@ -71,7 +73,9 @@ def mock_execute_factory(): ret.image_base64 = image_base64 ret.file_url = file_url yield ret + return mock_execute + return _create_execute @@ -86,6 +90,7 @@ def get_command_handler(): global _command_handler_module if _command_handler_module is None: from importlib import import_module + _command_handler_module = import_module('langbot.pkg.pipeline.process.handlers.command') return _command_handler_module @@ -95,12 +100,14 @@ def get_entities(): global _entities_module if _entities_module is None: from importlib import import_module + _entities_module = import_module('langbot.pkg.pipeline.entities') return _entities_module # ============== REAL CommandHandler Tests ============== + @pytest.mark.usefixtures('mock_circular_import_chain') class TestCommandHandlerReal: """Tests for real CommandHandler class.""" @@ -127,6 +134,7 @@ class TestCommandHandlerReal: fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) executed_commands = [] + async def track_execute(command_text, full_command_text, query, session): executed_commands.append(command_text) ret = Mock() @@ -334,8 +342,7 @@ class TestCommandHandlerReal: command = get_command_handler() fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) fake_app.cmd_mgr.execute = mock_execute_factory( - text='Here is the image:', - image_url='https://example.com/image.png' + text='Here is the image:', image_url='https://example.com/image.png' ) handler = command.CommandHandler(fake_app) @@ -393,4 +400,4 @@ class TestCommandHandlerHelper: command = get_command_handler() handler = command.CommandHandler(fake_app) result = handler.cut_str('first line\nsecond line') - assert '...' in result \ No newline at end of file + assert '...' in result diff --git a/tests/unit_tests/pipeline/test_longtext.py b/tests/unit_tests/pipeline/test_longtext.py index 1595cc18c..3a693276f 100644 --- a/tests/unit_tests/pipeline/test_longtext.py +++ b/tests/unit_tests/pipeline/test_longtext.py @@ -126,11 +126,9 @@ class TestLongTextProcessStageProcess: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config - query.resp_message_chain = [ - platform_message.MessageChain([platform_message.Plain(text="very long response")]) - ] + query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='very long response')])] result = await stage.process(query, 'LongTextProcessStage') @@ -151,11 +149,9 @@ class TestLongTextProcessStageProcess: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config - query.resp_message_chain = [ - platform_message.MessageChain([platform_message.Plain(text="short response")]) - ] + query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='short response')])] result = await stage.process(query, 'LongTextProcessStage') @@ -179,14 +175,13 @@ class TestLongTextProcessStageProcess: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config # Non-Plain component (Image) query.resp_message_chain = [ - platform_message.MessageChain([ - platform_message.Plain(text="short"), - platform_message.Image(url="https://example.com/img.png") - ]) + platform_message.MessageChain( + [platform_message.Plain(text='short'), platform_message.Image(url='https://example.com/img.png')] + ) ] result = await stage.process(query, 'LongTextProcessStage') @@ -213,7 +208,7 @@ class TestLongTextProcessStageProcess: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config query.resp_message_chain = [] @@ -232,7 +227,7 @@ class TestLongTextProcessStageProcess: stage = longtext.LongTextProcessStage(app) stage.strategy_impl = AsyncMock() - query = text_query("hello") + query = text_query('hello') query.pipeline_config = make_longtext_config(strategy='forward', threshold=1) query.resp_message_chain = [] @@ -242,6 +237,7 @@ class TestLongTextProcessStageProcess: assert result.new_query is query stage.strategy_impl.process.assert_not_called() + class TestForwardStrategy: """Tests for ForwardComponentStrategy.""" @@ -260,7 +256,7 @@ class TestForwardStrategy: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config # Create a mock adapter with bot_account_id mock_adapter = Mock() @@ -268,10 +264,8 @@ class TestForwardStrategy: query.adapter = mock_adapter # Long text exceeding threshold - long_text = "This is a very long response that exceeds the threshold" - query.resp_message_chain = [ - platform_message.MessageChain([platform_message.Plain(text=long_text)]) - ] + long_text = 'This is a very long response that exceeds the threshold' + query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text=long_text)])] result = await stage.process(query, 'LongTextProcessStage') @@ -297,13 +291,13 @@ class TestForwardStrategy: await strat.initialize() - query = text_query("hello") + query = text_query('hello') query.pipeline_config = make_longtext_config() mock_adapter = Mock() mock_adapter.bot_account_id = '12345' query.adapter = mock_adapter - components = await strat.process("test message", query) + components = await strat.process('test message', query) assert len(components) == 1 assert isinstance(components[0], platform_message.Forward) @@ -326,14 +320,12 @@ class TestLongTextThreshold: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config # Text below threshold - short_text = "x" * (threshold - 1) - query.resp_message_chain = [ - platform_message.MessageChain([platform_message.Plain(text=short_text)]) - ] + short_text = 'x' * (threshold - 1) + query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text=short_text)])] result = await stage.process(query, 'LongTextProcessStage') diff --git a/tests/unit_tests/pipeline/test_msgtrun.py b/tests/unit_tests/pipeline/test_msgtrun.py index 9cfdababf..4470c6945 100644 --- a/tests/unit_tests/pipeline/test_msgtrun.py +++ b/tests/unit_tests/pipeline/test_msgtrun.py @@ -115,7 +115,7 @@ class TestRoundTruncatorProcess: await stage.initialize(pipeline_config) # Create query with 3 messages (within limit) - query = text_query("current message") + query = text_query('current message') query.pipeline_config = pipeline_config query.messages = [ provider_message.Message(role='user', content='message 1'), @@ -154,7 +154,7 @@ class TestRoundTruncatorProcess: # Create query with many messages exceeding limit # 7 messages = 3 full rounds + 1 current user - query = text_query("current message") + query = text_query('current message') query.pipeline_config = pipeline_config query.messages = [ provider_message.Message(role='user', content='message 1'), @@ -194,7 +194,7 @@ class TestRoundTruncatorProcess: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config query.messages = [] @@ -216,7 +216,7 @@ class TestRoundTruncatorProcess: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config query.messages = [ provider_message.Message(role='user', content='hello'), @@ -240,7 +240,7 @@ class TestRoundTruncatorProcess: await stage.initialize(pipeline_config) - query = text_query("current") + query = text_query('current') query.pipeline_config = pipeline_config query.messages = [ provider_message.Message(role='user', content='user1'), @@ -274,7 +274,7 @@ class TestRoundTruncatorProcess: await stage.initialize(pipeline_config) - query = text_query("current") + query = text_query('current') query.pipeline_config = pipeline_config query.messages = [ provider_message.Message(role='user', content='old1'), @@ -305,7 +305,7 @@ class TestRoundTruncatorDirect: trun = trun_cls(app) break - query = text_query("hello") + query = text_query('hello') query.pipeline_config = make_truncate_config(max_round=3) query.messages = [ provider_message.Message(role='user', content='m1'), diff --git a/tests/unit_tests/pipeline/test_n8nsvapi.py b/tests/unit_tests/pipeline/test_n8nsvapi.py index b9bbcc2da..787472375 100644 --- a/tests/unit_tests/pipeline/test_n8nsvapi.py +++ b/tests/unit_tests/pipeline/test_n8nsvapi.py @@ -14,33 +14,43 @@ import json import sys from unittest.mock import AsyncMock, MagicMock, Mock, patch -# Break the circular import chain before importing n8nsvapi: +import pytest +import langbot_plugin.api.entities.builtin.provider.message as provider_message + +# Break the circular import chain while importing n8nsvapi: # n8nsvapi → runner → app → pipelinemgr → all runners → runner (partially init) -_mock_runner = MagicMock() -_mock_runner.runner_class = lambda name: (lambda cls: cls) # no-op decorator -_mock_runner.RequestRunner = object -_mocked_imports = { - 'langbot.pkg.provider.runner': _mock_runner, +# The stubs are restored in a ``finally`` block so this module does NOT pollute +# sys.modules for other test modules (e.g. ones importing the real +# LocalAgentRunner, which would otherwise inherit ``object`` and break). +# Mirrors master's intent but uses try/finally so a raised import doesn't +# leave the global namespace in a stubbed state, and includes +# ``langbot.pkg.utils.httpclient`` which master didn't stub. +_runner_stub = MagicMock() +_runner_stub.runner_class = lambda name: (lambda cls: cls) # no-op decorator +_runner_stub.RequestRunner = object +_import_stubs = { + 'langbot.pkg.provider.runner': _runner_stub, 'langbot.pkg.core.app': MagicMock(), + 'langbot.pkg.utils.httpclient': MagicMock(), } -_original_imports = {name: sys.modules.get(name) for name in _mocked_imports} -sys.modules.update(_mocked_imports) - -import pytest # noqa: E402 -import langbot_plugin.api.entities.builtin.provider.message as provider_message # noqa: E402 -from langbot.pkg.provider.runners.n8nsvapi import N8nServiceAPIRunner # noqa: E402 - -for _name, _original in _original_imports.items(): - if _original is None: - sys.modules.pop(_name, None) - else: - sys.modules[_name] = _original +_saved_modules = {name: sys.modules.get(name) for name in _import_stubs} +for _name, _stub in _import_stubs.items(): + sys.modules[_name] = _stub +try: + from langbot.pkg.provider.runners.n8nsvapi import N8nServiceAPIRunner +finally: + for _name, _original in _saved_modules.items(): + if _original is None: + sys.modules.pop(_name, None) + else: + sys.modules[_name] = _original # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner: ap = Mock() ap.logger = Mock() @@ -83,6 +93,7 @@ async def collect_chunks(runner: N8nServiceAPIRunner, chunks: list[bytes | str]) # _process_response: stream format (type:item/end) # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_stream_format_single_item(): """Single item + end in one chunk yields final chunk with full content.""" @@ -165,6 +176,7 @@ async def test_stream_format_no_spurious_empty_yield(): # _process_response: plain JSON fallback # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_plain_json_with_output_key(): """Plain JSON with matching output_key extracts value via output_key.""" @@ -235,6 +247,7 @@ async def test_invalid_json_returns_raw_text(): # _call_webhook: output type depends on is_stream # --------------------------------------------------------------------------- + def make_query(is_stream: bool): """Build a minimal Query mock.""" query = Mock() diff --git a/tests/unit_tests/pipeline/test_pipelinemgr.py b/tests/unit_tests/pipeline/test_pipelinemgr.py index f2e6780d6..49984542c 100644 --- a/tests/unit_tests/pipeline/test_pipelinemgr.py +++ b/tests/unit_tests/pipeline/test_pipelinemgr.py @@ -162,3 +162,46 @@ async def test_runtime_pipeline_execute(mock_app, sample_query): # Verify stage was called mock_stage.process.assert_called_once() + + +def test_runtime_pipeline_prefers_local_agent_mcp_resources(mock_app): + """Local Agent resource selection should override legacy extension prefs.""" + pipelinemgr = get_pipelinemgr_module() + persistence_pipeline = get_persistence_pipeline_module() + + pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) + pipeline_entity.config = { + 'ai': { + 'local-agent': { + 'mcp-resources': [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}], + 'mcp-resource-agent-read-enabled': False, + } + } + } + pipeline_entity.extensions_preferences = { + 'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}], + 'mcp_resource_agent_read_enabled': True, + } + + runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, []) + + assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}] + assert runtime_pipeline.mcp_resource_agent_read_enabled is False + + +def test_runtime_pipeline_falls_back_to_extension_mcp_resources(mock_app): + """Existing extension prefs remain compatible until a Local Agent value exists.""" + pipelinemgr = get_pipelinemgr_module() + persistence_pipeline = get_persistence_pipeline_module() + + pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) + pipeline_entity.config = {'ai': {'local-agent': {}}} + pipeline_entity.extensions_preferences = { + 'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}], + 'mcp_resource_agent_read_enabled': False, + } + + runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, []) + + assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}] + assert runtime_pipeline.mcp_resource_agent_read_enabled is False diff --git a/tests/unit_tests/pipeline/test_preproc.py b/tests/unit_tests/pipeline/test_preproc.py index 1413f5f74..15bf60801 100644 --- a/tests/unit_tests/pipeline/test_preproc.py +++ b/tests/unit_tests/pipeline/test_preproc.py @@ -14,6 +14,7 @@ from __future__ import annotations import pytest from unittest.mock import AsyncMock, Mock from importlib import import_module +from types import SimpleNamespace from tests.factories import ( FakeApp, @@ -78,7 +79,7 @@ class TestPreProcessorNormalText: app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) stage = preproc.PreProcessor(app) - query = text_query("hello world") + query = text_query('hello world') result = await stage.process(query, 'PreProcessor') @@ -113,7 +114,7 @@ class TestPreProcessorNormalText: app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) stage = preproc.PreProcessor(app) - query = text_query("test message") + query = text_query('test message') result = await stage.process(query, 'PreProcessor') @@ -194,13 +195,16 @@ class TestPreProcessorImageSegment: stage = preproc.PreProcessor(app) # Image query with base64 - query = image_query(text="look at this", url=None) + query = image_query(text='look at this', url=None) # Set base64 on the image component import langbot_plugin.api.entities.builtin.platform.message as platform_message - chain = platform_message.MessageChain([ - platform_message.Plain(text="look at this"), - platform_message.Image(base64="data:image/png;base64,abc123"), - ]) + + chain = platform_message.MessageChain( + [ + platform_message.Plain(text='look at this'), + platform_message.Image(base64='data:image/png;base64,abc123'), + ] + ) query.message_chain = chain result = await stage.process(query, 'PreProcessor') @@ -238,7 +242,7 @@ class TestPreProcessorImageSegment: app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) stage = preproc.PreProcessor(app) - query = image_query(text="describe this") + query = image_query(text='describe this') result = await stage.process(query, 'PreProcessor') @@ -276,7 +280,7 @@ class TestPreProcessorModelSelection: app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) stage = preproc.PreProcessor(app) - query = text_query("hello") + query = text_query('hello') # Set pipeline config with primary model query.pipeline_config = { @@ -335,7 +339,7 @@ class TestPreProcessorModelSelection: app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) stage = preproc.PreProcessor(app) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = { 'ai': { @@ -384,7 +388,7 @@ class TestPreProcessorVariables: app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) stage = preproc.PreProcessor(app) - query = text_query("hello", sender_id=67890) + query = text_query('hello', sender_id=67890) result = await stage.process(query, 'PreProcessor') @@ -421,10 +425,67 @@ class TestPreProcessorVariables: app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) stage = preproc.PreProcessor(app) - query = group_text_query("hello", group_id=99999) + query = group_text_query('hello', group_id=99999) result = await stage.process(query, 'PreProcessor') variables = result.new_query.variables assert 'group_name' in variables assert 'sender_name' in variables + + +class TestPreProcessorToolSelection: + """Tests for Local Agent tool selection.""" + + @pytest.mark.asyncio + async def test_local_agent_filters_selected_tools(self): + """Only selected tools should be exposed when all-tools mode is off.""" + preproc = get_preproc_module() + + app = FakeApp() + mock_session = Mock() + mock_session.launcher_type = Mock(value='person') + mock_session.launcher_id = 12345 + app.sess_mgr.get_session = AsyncMock(return_value=mock_session) + + mock_conversation = Mock() + mock_conversation.prompt = Mock(messages=[]) + mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[])) + mock_conversation.messages = [] + mock_conversation.uuid = None + app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) + + mock_model = Mock() + mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=['func_call']) + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) + app.tool_mgr.get_all_tools = AsyncMock( + return_value=[ + SimpleNamespace(name='exec'), + SimpleNamespace(name='plugin_tool'), + SimpleNamespace(name='mcp_tool'), + ] + ) + + mock_event_ctx = Mock() + mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) + app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) + + stage = preproc.PreProcessor(app) + query = text_query('hello') + query.pipeline_config = { + 'ai': { + 'runner': {'runner': 'local-agent'}, + 'local-agent': { + 'model': {'primary': 'primary-model-uuid', 'fallbacks': []}, + 'prompt': 'default', + 'enable-all-tools': False, + 'tools': ['plugin_tool'], + }, + }, + 'output': {'misc': {'at-sender': False}}, + 'trigger': {'misc': {}}, + } + + result = await stage.process(query, 'PreProcessor') + + assert [tool.name for tool in result.new_query.use_funcs] == ['plugin_tool'] diff --git a/tests/unit_tests/pipeline/test_ratelimit.py b/tests/unit_tests/pipeline/test_ratelimit.py index a06c3b674..be767ad56 100644 --- a/tests/unit_tests/pipeline/test_ratelimit.py +++ b/tests/unit_tests/pipeline/test_ratelimit.py @@ -46,7 +46,7 @@ class TestFixedWindowAlgo: 'safety': { 'rate-limit': { 'window-length': 60, # 60 seconds window - 'limitation': 10, # 10 requests per window + 'limitation': 10, # 10 requests per window 'strategy': 'drop', } } @@ -75,11 +75,9 @@ class TestFixedWindowAlgo: # Make requests within limit for i in range(10): result = await algo.require_access( - sample_query_with_rate_limit, - provider_session.LauncherTypes.PERSON, - '12345' + sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345' ) - assert result is True, f"Request {i+1} should be allowed" + assert result is True, f'Request {i + 1} should be allowed' @pytest.mark.asyncio async def test_fixedwin_exceeds_limit_drop_strategy(self, mock_app_for_algo, sample_query_with_rate_limit): @@ -91,20 +89,12 @@ class TestFixedWindowAlgo: # Exhaust the limit for i in range(10): - await algo.require_access( - sample_query_with_rate_limit, - provider_session.LauncherTypes.PERSON, - '12345' - ) + await algo.require_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345') # Next request should be denied - result = await algo.require_access( - sample_query_with_rate_limit, - provider_session.LauncherTypes.PERSON, - '12345' - ) + result = await algo.require_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345') - assert result is False, "Request exceeding limit should be denied" + assert result is False, 'Request exceeding limit should be denied' @pytest.mark.asyncio async def test_fixedwin_different_sessions_isolated(self, mock_app_for_algo, sample_query_with_rate_limit): @@ -116,20 +106,14 @@ class TestFixedWindowAlgo: # Exhaust limit for session 1 for i in range(10): - await algo.require_access( - sample_query_with_rate_limit, - provider_session.LauncherTypes.PERSON, - 'session1' - ) + await algo.require_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, 'session1') # Session 2 should still have its own limit result = await algo.require_access( - sample_query_with_rate_limit, - provider_session.LauncherTypes.PERSON, - 'session2' + sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, 'session2' ) - assert result is True, "Different session should have independent limit" + assert result is True, 'Different session should have independent limit' @pytest.mark.asyncio async def test_fixedwin_limit_one_request(self, mock_app_for_algo, sample_query): @@ -150,19 +134,11 @@ class TestFixedWindowAlgo: await algo.initialize() # First request allowed - result1 = await algo.require_access( - sample_query, - provider_session.LauncherTypes.PERSON, - '12345' - ) + result1 = await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, '12345') assert result1 is True # Second request denied - result2 = await algo.require_access( - sample_query, - provider_session.LauncherTypes.PERSON, - '12345' - ) + result2 = await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, '12345') assert result2 is False @pytest.mark.asyncio @@ -174,11 +150,7 @@ class TestFixedWindowAlgo: await algo.initialize() # First request creates container - await algo.require_access( - sample_query_with_rate_limit, - provider_session.LauncherTypes.PERSON, - '12345' - ) + await algo.require_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345') # Key format: 'LauncherTypes.PERSON_12345' (enum string representation) expected_key = 'LauncherTypes.PERSON_12345' @@ -230,7 +202,7 @@ class TestFixedWindowAlgo: # New request should be allowed (new window) result = await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, 'test') - assert result is True, "New window should allow new requests" + assert result is True, 'New window should allow new requests' @pytest.mark.asyncio async def test_fixedwin_wait_strategy_blocks_until_next_window(self, mock_app_for_algo, sample_query): @@ -256,29 +228,21 @@ class TestFixedWindowAlgo: # First request allowed start_time = time.time() - result1 = await algo.require_access( - sample_query, - provider_session.LauncherTypes.PERSON, - 'wait_test' - ) + result1 = await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, 'wait_test') assert result1 is True # Exhaust limit await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, 'wait_test') # Third request should wait and then succeed - result3 = await algo.require_access( - sample_query, - provider_session.LauncherTypes.PERSON, - 'wait_test' - ) + result3 = await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, 'wait_test') elapsed = time.time() - start_time - assert result3 is True, "After wait, request should succeed" + assert result3 is True, 'After wait, request should succeed' # Should have waited approximately until next window # With 1-second window, elapsed should be > 0.5 second (allowing for timing variance) # Note: This is a timing-sensitive test, so we use a generous tolerance - assert elapsed >= 0.5, f"Should have waited for next window, elapsed={elapsed:.2f}s" + assert elapsed >= 0.5, f'Should have waited for next window, elapsed={elapsed:.2f}s' @pytest.mark.asyncio async def test_fixedwin_release_access(self, mock_app_for_algo, sample_query_with_rate_limit): @@ -289,11 +253,7 @@ class TestFixedWindowAlgo: await algo.initialize() # release_access is empty in current implementation - await algo.release_access( - sample_query_with_rate_limit, - provider_session.LauncherTypes.PERSON, - '12345' - ) + await algo.release_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345') # Should not raise or change state assert 'person_12345' not in algo.containers diff --git a/tests/unit_tests/pipeline/test_wrapper.py b/tests/unit_tests/pipeline/test_wrapper.py index e5d47c76f..034c14115 100644 --- a/tests/unit_tests/pipeline/test_wrapper.py +++ b/tests/unit_tests/pipeline/test_wrapper.py @@ -36,6 +36,11 @@ def get_entities_module(): return import_module('langbot.pkg.pipeline.entities') +def get_plugin_diagnostics_module(): + """Lazy import for plugin diagnostic attribution helpers.""" + return import_module('langbot.pkg.pipeline.plugin_diagnostics') + + def make_wrapper_config(): """Create a pipeline config for wrapper tests.""" return { @@ -55,7 +60,7 @@ def make_session(): launcher_type=provider_session.LauncherTypes.PERSON, launcher_id=12345, sender_id=12345, - use_prompt_name="default", + use_prompt_name='default', using_conversation=None, conversations=[], ) @@ -93,11 +98,9 @@ class TestResponseWrapperMessageChain: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config - query.resp_messages = [ - platform_message.MessageChain([platform_message.Plain(text="response")]) - ] + query.resp_messages = [platform_message.MessageChain([platform_message.Plain(text='response')])] query.resp_message_chain = [] results = [] @@ -108,6 +111,45 @@ class TestResponseWrapperMessageChain: assert results[0].result_type == entities.ResultType.CONTINUE assert len(results[0].new_query.resp_message_chain) == 1 + @pytest.mark.asyncio + async def test_message_chain_direct_append_consumes_pending_plugin_source(self): + """MessageChain replies from earlier plugin events keep attribution.""" + wrapper = get_wrapper_module() + + app = FakeApp() + stage = wrapper.ResponseWrapper(app) + await stage.initialize(make_wrapper_config()) + + reply_chain = platform_message.MessageChain([platform_message.Plain(text='response')]) + query = text_query('hello') + query.pipeline_config = make_wrapper_config() + query.resp_messages = [reply_chain] + query.resp_message_chain = [] + plugin_diagnostics = get_plugin_diagnostics_module() + plugin_diagnostics.record_pending_plugin_response_source( + query, + reply_chain, + [ + { + 'kind': 'reply_message_chain', + 'plugin': {'author': 'tester', 'name': 'demo'}, + } + ], + [{'manifest': {'metadata': {'author': 'observer', 'name': 'not-reply-source'}}}], + 'PersonNormalMessageReceived', + ) + + results = [] + async for result in stage.process(query, 'ResponseWrapper'): + results.append(result) + + sources = plugin_diagnostics._get_response_sources(results[0].new_query, 0) + assert sources[0].plugin == {'author': 'tester', 'name': 'demo'} + assert sources[0].event_name == 'PersonNormalMessageReceived' + assert sources[0].is_approximate is False + assert '_plugin_response_sources' not in query.variables + assert '_plugin_pending_response_sources' not in query.variables + class TestResponseWrapperCommand: """Tests for command response wrapping.""" @@ -125,7 +167,7 @@ class TestResponseWrapperCommand: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config query.resp_message_chain = [] @@ -133,7 +175,7 @@ class TestResponseWrapperCommand: command_resp = Mock() command_resp.role = 'command' command_resp.get_content_platform_message_chain = Mock( - return_value=platform_message.MessageChain([platform_message.Plain(text="Help info")]) + return_value=platform_message.MessageChain([platform_message.Plain(text='Help info')]) ) query.resp_messages = [command_resp] @@ -163,7 +205,7 @@ class TestResponseWrapperPlugin: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config query.resp_message_chain = [] @@ -171,7 +213,7 @@ class TestResponseWrapperPlugin: plugin_resp = Mock() plugin_resp.role = 'plugin' plugin_resp.get_content_platform_message_chain = Mock( - return_value=platform_message.MessageChain([platform_message.Plain(text="Plugin response")]) + return_value=platform_message.MessageChain([platform_message.Plain(text='Plugin response')]) ) query.resp_messages = [plugin_resp] @@ -211,17 +253,17 @@ class TestResponseWrapperAssistant: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config query.resp_message_chain = [] # Create assistant response with content assistant_resp = Mock() assistant_resp.role = 'assistant' - assistant_resp.content = "Hello back!" + assistant_resp.content = 'Hello back!' assistant_resp.tool_calls = None assistant_resp.get_content_platform_message_chain = Mock( - return_value=platform_message.MessageChain([platform_message.Plain(text="Hello back!")]) + return_value=platform_message.MessageChain([platform_message.Plain(text='Hello back!')]) ) query.resp_messages = [assistant_resp] @@ -247,7 +289,7 @@ class TestResponseWrapperAssistant: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config query.resp_message_chain = [] @@ -292,7 +334,7 @@ class TestResponseWrapperAssistant: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config query.resp_message_chain = [] @@ -303,10 +345,10 @@ class TestResponseWrapperAssistant: assistant_resp = Mock() assistant_resp.role = 'assistant' - assistant_resp.content = "Processing..." + assistant_resp.content = 'Processing...' assistant_resp.tool_calls = [mock_tool_call] assistant_resp.get_content_platform_message_chain = Mock( - return_value=platform_message.MessageChain([platform_message.Plain(text="Processing...")]) + return_value=platform_message.MessageChain([platform_message.Plain(text='Processing...')]) ) query.resp_messages = [assistant_resp] @@ -346,17 +388,17 @@ class TestResponseWrapperInterrupt: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config query.resp_message_chain = [] # Create assistant response with content assistant_resp = Mock() assistant_resp.role = 'assistant' - assistant_resp.content = "Hello!" + assistant_resp.content = 'Hello!' assistant_resp.tool_calls = None assistant_resp.get_content_platform_message_chain = Mock( - return_value=platform_message.MessageChain([platform_message.Plain(text="Hello!")]) + return_value=platform_message.MessageChain([platform_message.Plain(text='Hello!')]) ) query.resp_messages = [assistant_resp] @@ -384,7 +426,7 @@ class TestResponseWrapperCustomReply: app.sess_mgr.get_session = AsyncMock(return_value=session) # Mock plugin connector with custom reply - custom_chain = platform_message.MessageChain([platform_message.Plain(text="Custom reply")]) + custom_chain = platform_message.MessageChain([platform_message.Plain(text='Custom reply')]) mock_event_ctx = Mock() mock_event_ctx.is_prevented_default = Mock(return_value=False) mock_event_ctx.event = Mock() @@ -397,17 +439,17 @@ class TestResponseWrapperCustomReply: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config query.resp_message_chain = [] # Create assistant response assistant_resp = Mock() assistant_resp.role = 'assistant' - assistant_resp.content = "Default reply" + assistant_resp.content = 'Default reply' assistant_resp.tool_calls = None assistant_resp.get_content_platform_message_chain = Mock( - return_value=platform_message.MessageChain([platform_message.Plain(text="Default reply")]) + return_value=platform_message.MessageChain([platform_message.Plain(text='Default reply')]) ) query.resp_messages = [assistant_resp] @@ -421,7 +463,105 @@ class TestResponseWrapperCustomReply: assert len(results[0].new_query.resp_message_chain) == 1 # Should be the custom chain chain = results[0].new_query.resp_message_chain[0] - assert "Custom reply" in str(chain) + assert 'Custom reply' in str(chain) + + @pytest.mark.asyncio + async def test_custom_reply_records_plugin_source(self): + """Plugin reply_message_chain should keep emitted plugin attribution.""" + wrapper = get_wrapper_module() + + app = FakeApp() + app.sess_mgr.get_session = AsyncMock(return_value=make_session()) + + custom_chain = platform_message.MessageChain([platform_message.Plain(text='Custom reply')]) + mock_event_ctx = Mock() + mock_event_ctx.is_prevented_default = Mock(return_value=False) + mock_event_ctx.event = Mock() + mock_event_ctx.event.reply_message_chain = custom_chain + mock_event_ctx._emitted_plugins = [ + { + 'manifest': {'metadata': {'author': 'observer', 'name': 'not-reply-source'}}, + 'plugin_config': {'token': 'secret-token'}, + }, + ] + mock_event_ctx._response_sources = [ + { + 'kind': 'reply_message_chain', + 'plugin': {'author': 'tester', 'name': 'demo'}, + } + ] + app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) + + stage = wrapper.ResponseWrapper(app) + pipeline_config = make_wrapper_config() + await stage.initialize(pipeline_config) + + query = text_query('hello') + query.pipeline_config = pipeline_config + query.resp_message_chain = [] + assistant_resp = Mock() + assistant_resp.role = 'assistant' + assistant_resp.content = 'Default reply' + assistant_resp.tool_calls = None + assistant_resp.get_content_platform_message_chain = Mock( + return_value=platform_message.MessageChain([platform_message.Plain(text='Default reply')]) + ) + query.resp_messages = [assistant_resp] + + results = [] + async for result in stage.process(query, 'ResponseWrapper'): + results.append(result) + + plugin_diagnostics = get_plugin_diagnostics_module() + sources = plugin_diagnostics._get_response_sources(results[0].new_query, 0) + assert sources[0].plugin == {'author': 'tester', 'name': 'demo'} + assert sources[0].event_name == 'NormalMessageResponded' + assert sources[0].is_approximate is False + assert 'secret-token' not in str(sources) + assert '_plugin_response_sources' not in query.variables + + @pytest.mark.asyncio + async def test_custom_reply_falls_back_to_emitted_plugins_for_old_runtime(self): + """Older plugin runtimes without response_sources keep approximate attribution.""" + wrapper = get_wrapper_module() + + app = FakeApp() + app.sess_mgr.get_session = AsyncMock(return_value=make_session()) + + custom_chain = platform_message.MessageChain([platform_message.Plain(text='Custom reply')]) + mock_event_ctx = Mock() + mock_event_ctx.is_prevented_default = Mock(return_value=False) + mock_event_ctx.event = Mock() + mock_event_ctx.event.reply_message_chain = custom_chain + mock_event_ctx._emitted_plugins = [ + {'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}}, + ] + app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) + + stage = wrapper.ResponseWrapper(app) + pipeline_config = make_wrapper_config() + await stage.initialize(pipeline_config) + + query = text_query('hello') + query.pipeline_config = pipeline_config + query.resp_message_chain = [] + assistant_resp = Mock() + assistant_resp.role = 'assistant' + assistant_resp.content = 'Default reply' + assistant_resp.tool_calls = None + assistant_resp.get_content_platform_message_chain = Mock( + return_value=platform_message.MessageChain([platform_message.Plain(text='Default reply')]) + ) + query.resp_messages = [assistant_resp] + + results = [] + async for result in stage.process(query, 'ResponseWrapper'): + results.append(result) + + plugin_diagnostics = get_plugin_diagnostics_module() + sources = plugin_diagnostics._get_response_sources(results[0].new_query, 0) + assert sources[0].plugin == {'author': 'tester', 'name': 'demo'} + assert sources[0].is_approximate is True class TestResponseWrapperVariables: @@ -452,7 +592,7 @@ class TestResponseWrapperVariables: await stage.initialize(pipeline_config) - query = text_query("hello") + query = text_query('hello') query.pipeline_config = pipeline_config query.resp_message_chain = [] query.variables['_pipeline_bound_plugins'] = ['plugin1', 'plugin2'] @@ -460,10 +600,10 @@ class TestResponseWrapperVariables: # Create assistant response assistant_resp = Mock() assistant_resp.role = 'assistant' - assistant_resp.content = "Hello" + assistant_resp.content = 'Hello' assistant_resp.tool_calls = None assistant_resp.get_content_platform_message_chain = Mock( - return_value=platform_message.MessageChain([platform_message.Plain(text="Hello")]) + return_value=platform_message.MessageChain([platform_message.Plain(text='Hello')]) ) query.resp_messages = [assistant_resp] diff --git a/tests/unit_tests/pipeline/test_wrapper_outbound_attachments.py b/tests/unit_tests/pipeline/test_wrapper_outbound_attachments.py new file mode 100644 index 000000000..8fc000bf5 --- /dev/null +++ b/tests/unit_tests/pipeline/test_wrapper_outbound_attachments.py @@ -0,0 +1,146 @@ +"""Unit tests for ResponseWrapper outbound-attachment helpers. + +Covers the sandbox -> user attachment path added for the Box attachment +round-trip: + +* ``_is_final_assistant_message`` — only the terminal, tool-call-free assistant + message (or a final MessageChunk) should trigger collection. +* ``_append_outbound_attachments`` — collects sandbox outbox files exactly once + per query and maps each descriptor to the right platform component, swallowing + collection errors. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.provider.message as provider_message + +from langbot.pkg.pipeline.wrapper.wrapper import ResponseWrapper + + +def _make_wrapper(box_service) -> ResponseWrapper: + app = SimpleNamespace(logger=Mock()) + wrapper = ResponseWrapper.__new__(ResponseWrapper) + wrapper.ap = app + return wrapper + + +def _make_query(): + return SimpleNamespace(variables={}) + + +def test_is_final_assistant_message_plain_assistant(): + wrapper = _make_wrapper(box_service=None) + msg = provider_message.Message(role='assistant', content='done') + assert wrapper._is_final_assistant_message(msg) is True + + +def test_is_final_assistant_message_rejects_non_assistant(): + wrapper = _make_wrapper(box_service=None) + msg = provider_message.Message(role='tool', content='{}') + assert wrapper._is_final_assistant_message(msg) is False + + +def test_is_final_assistant_message_rejects_tool_call_round(): + wrapper = _make_wrapper(box_service=None) + msg = provider_message.Message( + role='assistant', + content='calling', + tool_calls=[ + provider_message.ToolCall( + id='c1', + type='function', + function=provider_message.FunctionCall(name='exec', arguments='{}'), + ) + ], + ) + assert wrapper._is_final_assistant_message(msg) is False + + +def test_is_final_assistant_message_non_final_chunk(): + wrapper = _make_wrapper(box_service=None) + chunk = provider_message.MessageChunk(role='assistant', content='partial', is_final=False) + assert wrapper._is_final_assistant_message(chunk) is False + + final_chunk = provider_message.MessageChunk(role='assistant', content='partial', is_final=True) + assert wrapper._is_final_assistant_message(final_chunk) is True + + +@pytest.mark.asyncio +async def test_append_outbound_attachments_maps_each_type(): + box_service = SimpleNamespace( + available=True, + collect_outbound_attachments=AsyncMock( + return_value=[ + {'type': 'Image', 'base64': 'data:image/png;base64,iVBORw0K'}, + {'type': 'Voice', 'base64': 'data:audio/wav;base64,UklGRg=='}, + {'type': 'File', 'name': 'report.xlsx', 'base64': 'data:app;base64,UEsDBA=='}, + ] + ), + ) + wrapper = _make_wrapper(box_service) + wrapper.ap.box_service = box_service + query = _make_query() + chain = platform_message.MessageChain([]) + + await wrapper._append_outbound_attachments(query, chain) + + kinds = [type(c).__name__ for c in chain] + assert kinds == ['Image', 'Voice', 'File'] + assert query.variables['_sandbox_outbound_collected'] is True + # File keeps its name + file_comp = chain[2] + assert getattr(file_comp, 'name', None) == 'report.xlsx' + + +@pytest.mark.asyncio +async def test_append_outbound_attachments_runs_once_per_query(): + box_service = SimpleNamespace( + available=True, + collect_outbound_attachments=AsyncMock(return_value=[]), + ) + wrapper = _make_wrapper(box_service) + wrapper.ap.box_service = box_service + query = _make_query() + query.variables['_sandbox_outbound_collected'] = True + chain = platform_message.MessageChain([]) + + await wrapper._append_outbound_attachments(query, chain) + + box_service.collect_outbound_attachments.assert_not_awaited() + assert len(chain) == 0 + + +@pytest.mark.asyncio +async def test_append_outbound_attachments_noop_without_box_service(): + wrapper = _make_wrapper(box_service=None) + wrapper.ap.box_service = None + query = _make_query() + chain = platform_message.MessageChain([]) + + await wrapper._append_outbound_attachments(query, chain) + assert len(chain) == 0 + # not marked collected, since service is unavailable + assert '_sandbox_outbound_collected' not in query.variables + + +@pytest.mark.asyncio +async def test_append_outbound_attachments_swallows_collection_error(): + box_service = SimpleNamespace( + available=True, + collect_outbound_attachments=AsyncMock(side_effect=RuntimeError('boom')), + ) + wrapper = _make_wrapper(box_service) + wrapper.ap.box_service = box_service + query = _make_query() + chain = platform_message.MessageChain([]) + + # must not raise + await wrapper._append_outbound_attachments(query, chain) + assert len(chain) == 0 + wrapper.ap.logger.warning.assert_called_once() diff --git a/tests/unit_tests/platform/test_aiocqhttp_message_converter.py b/tests/unit_tests/platform/test_aiocqhttp_message_converter.py new file mode 100644 index 000000000..2f97176ec --- /dev/null +++ b/tests/unit_tests/platform/test_aiocqhttp_message_converter.py @@ -0,0 +1,538 @@ +import pytest +import aiocqhttp + +import langbot_plugin.api.entities.builtin.platform.message as platform_message +from langbot.pkg.platform.sources.aiocqhttp import ( + AiocqhttpAdapter, + AiocqhttpEventConverter, + AiocqhttpMessageConverter, +) + + +async def _convert_single(component: platform_message.MessageComponent): + chain = platform_message.MessageChain([component]) + message, _, _ = await AiocqhttpMessageConverter.yiri2target(chain) + return message[0] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('payload', 'expected'), + [ + ('data:image/jpeg;base64,raw-image', 'base64://raw-image'), + ('raw-image', 'base64://raw-image'), + ('base64://raw-image', 'base64://raw-image'), + ], +) +async def test_image_base64_payload_is_normalized(payload, expected): + segment = await _convert_single(platform_message.Image(base64=payload)) + + assert segment.type == 'image' + assert segment.data['file'] == expected + + +@pytest.mark.asyncio +async def test_voice_data_uri_base64_payload_is_normalized(): + segment = await _convert_single(platform_message.Voice(base64='data:audio/wav;base64,raw-voice')) + + assert segment.type == 'record' + assert segment.data['file'] == 'base64://raw-voice' + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('component', 'expected'), + [ + ( + platform_message.File(name='report.txt', base64='data:text/plain;base64,raw-file'), + {'file': 'base64://raw-file', 'name': 'report.txt'}, + ), + ( + platform_message.File(name='report.txt', base64='raw-file'), + {'file': 'base64://raw-file', 'name': 'report.txt'}, + ), + ( + platform_message.File(name='a.txt', url='http://example.com/a.txt'), + {'file': 'http://example.com/a.txt', 'name': 'a.txt'}, + ), + ( + platform_message.File(name='a.txt', path='/tmp/a.txt'), + {'file': '/tmp/a.txt', 'name': 'a.txt'}, + ), + ], +) +async def test_file_message_uses_available_file_source(component, expected): + segment = await _convert_single(component) + + assert segment.type == 'file' + assert segment.data == expected + + +@pytest.mark.asyncio +async def test_forward_image_base64_payload_is_normalized(): + forward = platform_message.Forward( + node_list=[ + platform_message.ForwardMessageNode( + sender_id='10001', + sender_name='Tester', + message_chain=platform_message.MessageChain( + [platform_message.Image(base64='data:image/png;base64,raw-forward-image')] + ), + ) + ] + ) + messages = [] + + class Logger: + async def info(self, _message): + return None + + async def error(self, _message): + return None + + class Bot: + async def call_action(self, action, **kwargs): + assert action == 'send_forward_msg' + messages.append(kwargs) + + platform = AiocqhttpAdapter.model_construct( + bot_account_id='10000', + config={}, + logger=Logger(), + bot=Bot(), + ) + + await platform._send_forward_message(1000, forward) + + assert messages[0]['messages'][0]['data']['content'][0] == { + 'type': 'image', + 'data': {'file': 'base64://raw-forward-image'}, + } + + +@pytest.mark.asyncio +async def test_group_message_member_name_prefers_group_card(): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': 'Special Title', + }, + } + ) + + class Bot: + async def get_group_info(self, group_id): + assert group_id == 2000 + return {'group_id': group_id, 'group_name': 'Test Group'} + + converted = await AiocqhttpEventConverter().target2yiri(event, Bot()) + + assert converted.sender.member_name == 'Group Card' + assert converted.sender.group.id == 2000 + assert converted.sender.group.name == 'Test Group' + assert converted.sender.special_title == 'Special Title' + + +@pytest.mark.asyncio +async def test_group_message_member_name_falls_back_to_nickname(): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': '', + 'role': 'member', + }, + } + ) + + converted = await AiocqhttpEventConverter().target2yiri(event) + + assert converted.sender.member_name == 'QQ Nickname' + + +@pytest.mark.asyncio +async def test_group_message_special_title_uses_group_member_info_when_sender_title_is_empty(): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': '', + }, + } + ) + + class Bot: + async def get_group_info(self, group_id): + return {'group_id': group_id, 'group_name': 'Test Group'} + + async def get_group_member_info(self, group_id, user_id): + assert group_id == 2000 + assert user_id == 3000 + return {'group_id': group_id, 'user_id': user_id, 'title': 'Member Title'} + + converted = await AiocqhttpEventConverter().target2yiri(event, Bot()) + + assert converted.sender.special_title == 'Member Title' + + +@pytest.mark.asyncio +async def test_group_message_special_title_does_not_lookup_when_sender_title_exists(): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': 'Event Title', + }, + } + ) + + class Bot: + async def get_group_info(self, group_id): + return {'group_id': group_id, 'group_name': 'Test Group'} + + async def get_group_member_info(self, group_id, user_id): + raise AssertionError('get_group_member_info should not be called') + + converted = await AiocqhttpEventConverter().target2yiri(event, Bot()) + + assert converted.sender.special_title == 'Event Title' + + +@pytest.mark.asyncio +async def test_group_message_special_title_member_info_failure_is_cached(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': '', + }, + } + ) + now = 1000.0 + + class Bot: + member_info_calls = 0 + + async def get_group_info(self, group_id): + return {'group_id': group_id, 'group_name': 'Test Group'} + + async def get_group_member_info(self, group_id, user_id): + self.member_info_calls += 1 + raise RuntimeError('api unavailable') + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + first = await converter.target2yiri(event, bot) + second = await converter.target2yiri(event, bot) + + assert first.sender.special_title == '' + assert second.sender.special_title == '' + assert bot.member_info_calls == 1 + + +@pytest.mark.asyncio +async def test_group_message_special_title_member_info_cache_expires(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': '', + }, + } + ) + now = 1000.0 + + class Bot: + member_info_calls = 0 + + async def get_group_info(self, group_id): + return {'group_id': group_id, 'group_name': 'Test Group'} + + async def get_group_member_info(self, group_id, user_id): + self.member_info_calls += 1 + return { + 'group_id': group_id, + 'user_id': user_id, + 'title': f'Member Title {self.member_info_calls}', + } + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + first = await converter.target2yiri(event, bot) + now = 87401.0 + second = await converter.target2yiri(event, bot) + + assert first.sender.special_title == 'Member Title 1' + assert second.sender.special_title == 'Member Title 2' + assert bot.member_info_calls == 2 + + +@pytest.mark.asyncio +async def test_group_message_special_title_retries_after_negative_cache_expires(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': '', + }, + } + ) + now = 1000.0 + + class Bot: + member_info_calls = 0 + + async def get_group_info(self, group_id): + return {'group_id': group_id, 'group_name': 'Test Group'} + + async def get_group_member_info(self, group_id, user_id): + self.member_info_calls += 1 + if self.member_info_calls == 1: + raise RuntimeError('api unavailable') + return {'group_id': group_id, 'user_id': user_id, 'title': 'Recovered Title'} + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + failed = await converter.target2yiri(event, bot) + now = 1601.0 + recovered = await converter.target2yiri(event, bot) + + assert failed.sender.special_title == '' + assert recovered.sender.special_title == 'Recovered Title' + assert bot.member_info_calls == 2 + + +@pytest.mark.asyncio +async def test_group_message_group_name_is_cached(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + }, + } + ) + + class Bot: + calls = 0 + + async def get_group_info(self, group_id): + self.calls += 1 + assert group_id == 2000 + return {'group_id': group_id, 'group_name': 'Cached Group'} + + monotonic = 1000.0 + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: monotonic) + + bot = Bot() + converter = AiocqhttpEventConverter() + + first = await converter.target2yiri(event, bot) + second = await converter.target2yiri(event, bot) + + assert first.sender.group.name == 'Cached Group' + assert second.sender.group.name == 'Cached Group' + assert bot.calls == 1 + + +@pytest.mark.asyncio +async def test_group_message_group_name_cache_expires(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + }, + } + ) + now = 1000.0 + + class Bot: + calls = 0 + + async def get_group_info(self, group_id): + self.calls += 1 + return {'group_id': group_id, 'group_name': f'Group Name {self.calls}'} + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + first = await converter.target2yiri(event, bot) + now = 4601.0 + second = await converter.target2yiri(event, bot) + + assert first.sender.group.name == 'Group Name 1' + assert second.sender.group.name == 'Group Name 2' + assert bot.calls == 2 + + +@pytest.mark.asyncio +async def test_group_message_group_name_uses_placeholder_when_lookup_fails(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + }, + } + ) + now = 1000.0 + + class Bot: + calls = 0 + + async def get_group_info(self, group_id): + self.calls += 1 + raise RuntimeError('api unavailable') + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + converted = await converter.target2yiri(event, bot) + cached_failure = await converter.target2yiri(event, bot) + + assert converted.sender.group.name == 'Group 2000' + assert cached_failure.sender.group.name == 'Group 2000' + assert bot.calls == 1 + + +@pytest.mark.asyncio +async def test_group_message_group_name_retries_after_negative_cache_expires(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + }, + } + ) + now = 1000.0 + + class Bot: + calls = 0 + + async def get_group_info(self, group_id): + self.calls += 1 + if self.calls == 1: + raise RuntimeError('api unavailable') + return {'group_id': group_id, 'group_name': 'Recovered Group'} + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + failed = await converter.target2yiri(event, bot) + now = 1061.0 + recovered = await converter.target2yiri(event, bot) + + assert failed.sender.group.name == 'Group 2000' + assert recovered.sender.group.name == 'Recovered Group' + assert bot.calls == 2 diff --git a/tests/unit_tests/platform/test_websocket_adapter_attachments.py b/tests/unit_tests/platform/test_websocket_adapter_attachments.py new file mode 100644 index 000000000..18138383d --- /dev/null +++ b/tests/unit_tests/platform/test_websocket_adapter_attachments.py @@ -0,0 +1,92 @@ +"""Unit tests for WebSocketAdapter._process_image_components. + +The web debug client uploads Image / Voice / File components carrying a storage +key in ``path``. This helper resolves each to a base64 data URI (so multimodal +LLM input and the Box sandbox inbox have usable bytes), then deletes the +consumed storage object and clears ``path``. Covers mimetype selection per +type and graceful error handling. +""" + +from __future__ import annotations + +import base64 +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter + + +def _make_adapter(load_return=b'hello', load_side_effect=None): + provider = Mock() + provider.load = AsyncMock(return_value=load_return, side_effect=load_side_effect) + provider.delete = AsyncMock() + ap = Mock() + ap.storage_mgr.storage_provider = provider + logger = Mock() + logger.error = AsyncMock() + # WebSocketAdapter is a pydantic model; bypass full __init__/validation. + adapter = WebSocketAdapter.model_construct(ap=ap, logger=logger) + return adapter, provider + + +@pytest.mark.asyncio +async def test_image_jpeg_mimetype_and_cleanup(): + adapter, provider = _make_adapter(load_return=b'\xff\xd8\xff') + chain = [{'type': 'Image', 'path': 'storage://abc/photo.jpg'}] + + await adapter._process_image_components(chain) + + expected_b64 = base64.b64encode(b'\xff\xd8\xff').decode('utf-8') + assert chain[0]['base64'] == f'data:image/jpeg;base64,{expected_b64}' + assert chain[0]['path'] == '' # consumed + provider.delete.assert_awaited_once_with('storage://abc/photo.jpg') + + +@pytest.mark.asyncio +async def test_image_defaults_to_png(): + adapter, _ = _make_adapter() + chain = [{'type': 'Image', 'path': 'storage://abc/blob'}] + await adapter._process_image_components(chain) + assert chain[0]['base64'].startswith('data:image/png;base64,') + + +@pytest.mark.asyncio +async def test_voice_uses_guessed_or_wav_mimetype(): + adapter, _ = _make_adapter() + chain = [{'type': 'Voice', 'path': 'storage://abc/clip.wav'}] + await adapter._process_image_components(chain) + assert chain[0]['base64'].startswith('data:audio/') + + +@pytest.mark.asyncio +async def test_file_uses_octet_stream_fallback(): + adapter, _ = _make_adapter() + chain = [{'type': 'File', 'path': 'storage://abc/unknownblob'}] + await adapter._process_image_components(chain) + assert chain[0]['base64'].startswith('data:application/octet-stream;base64,') + + +@pytest.mark.asyncio +async def test_skips_components_without_path_or_unknown_type(): + adapter, provider = _make_adapter() + chain = [ + {'type': 'Image', 'path': ''}, # no path + {'type': 'Plain', 'path': 'storage://abc/x'}, # not a file component + {'type': 'At', 'target': '123'}, # no path key at all + ] + await adapter._process_image_components(chain) + provider.load.assert_not_awaited() + assert 'base64' not in chain[0] + assert 'base64' not in chain[1] + + +@pytest.mark.asyncio +async def test_load_failure_is_logged_not_raised(): + adapter, _ = _make_adapter(load_side_effect=RuntimeError('storage down')) + chain = [{'type': 'File', 'path': 'storage://abc/doc.pdf'}] + + # must not raise + await adapter._process_image_components(chain) + assert 'base64' not in chain[0] + adapter.logger.error.assert_awaited_once() diff --git a/tests/unit_tests/platform/test_wecomcs_adapter.py b/tests/unit_tests/platform/test_wecomcs_adapter.py new file mode 100644 index 000000000..ee3425b61 --- /dev/null +++ b/tests/unit_tests/platform/test_wecomcs_adapter.py @@ -0,0 +1,91 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +import langbot_plugin.api.entities.builtin.platform.message as platform_message +from langbot.pkg.platform.sources.wecomcs import WecomCSAdapter + + +class DummyLogger(abstract_platform_logger.AbstractEventLogger): + async def info(self, *args, **kwargs): + pass + + async def debug(self, *args, **kwargs): + pass + + async def warning(self, *args, **kwargs): + pass + + async def error(self, *args, **kwargs): + pass + + +def make_adapter(): + return WecomCSAdapter( + config={ + 'corpid': 'corp-id', + 'secret': 'secret', + 'token': 'token', + 'EncodingAESKey': 'encoding-key', + }, + logger=DummyLogger(), + ) + + +@pytest.mark.asyncio +async def test_send_message_sends_text_to_customer_service_user(): + adapter = make_adapter() + adapter.bot_account_id = 'kf-test' + adapter.bot = SimpleNamespace(send_text_msg=AsyncMock()) + + message = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + await adapter.send_message('person', 'uexternal-user', message) + + adapter.bot.send_text_msg.assert_awaited_once() + kwargs = adapter.bot.send_text_msg.await_args.kwargs + assert kwargs['open_kfid'] == 'kf-test' + assert kwargs['external_userid'] == 'external-user' + assert kwargs['content'] == 'hello' + assert kwargs['msgid'].startswith('langbot_') + + +@pytest.mark.asyncio +async def test_send_message_allows_explicit_open_kfid_in_target_id(): + adapter = make_adapter() + adapter.bot = SimpleNamespace(send_text_msg=AsyncMock()) + + message = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + await adapter.send_message('person', 'kf-explicit|uexternal-user', message) + + kwargs = adapter.bot.send_text_msg.await_args.kwargs + assert kwargs['open_kfid'] == 'kf-explicit' + assert kwargs['external_userid'] == 'external-user' + + +@pytest.mark.asyncio +async def test_send_message_requires_open_kfid(): + adapter = make_adapter() + adapter.bot = SimpleNamespace(send_text_msg=AsyncMock()) + message = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + with pytest.raises(ValueError, match='open_kfid is required'): + await adapter.send_message('person', 'uexternal-user', message) + + adapter.bot.send_text_msg.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_message_rejects_group_targets(): + adapter = make_adapter() + adapter.bot_account_id = 'kf-test' + adapter.bot = SimpleNamespace(send_text_msg=AsyncMock()) + message = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + with pytest.raises(ValueError, match='only supports sending messages to person'): + await adapter.send_message('group', 'group-id', message) + + adapter.bot.send_text_msg.assert_not_called() diff --git a/tests/unit_tests/plugin/test_connector_methods.py b/tests/unit_tests/plugin/test_connector_methods.py index 10ce24191..34cab5271 100644 --- a/tests/unit_tests/plugin/test_connector_methods.py +++ b/tests/unit_tests/plugin/test_connector_methods.py @@ -6,12 +6,15 @@ Tests cover: - RAG methods (ingest, retrieve, schema) - Disabled plugin early returns """ + from __future__ import annotations import pytest from unittest.mock import Mock, AsyncMock from importlib import import_module +from tests.factories import text_query + def get_connector_module(): """Lazy import to avoid circular import issues.""" @@ -86,16 +89,12 @@ class TestListPlugins: return_value=[ { 'manifest': {'manifest': {'metadata': {'author': 'a', 'name': 'p1'}}}, - 'components': [ - {'manifest': {'manifest': {'kind': 'Command'}}} - ], + 'components': [{'manifest': {'manifest': {'kind': 'Command'}}}], 'debug': False, }, { 'manifest': {'manifest': {'metadata': {'author': 'b', 'name': 'p2'}}}, - 'components': [ - {'manifest': {'manifest': {'kind': 'Tool'}}} - ], + 'components': [{'manifest': {'manifest': {'kind': 'Tool'}}}], 'debug': False, }, ] @@ -127,9 +126,7 @@ class TestListPlugins: }, ] ) - connector.ap.persistence_mgr.execute_async = AsyncMock( - return_value=Mock(__iter__=lambda self: iter([])) - ) + connector.ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock(__iter__=lambda self: iter([]))) result = await connector.list_plugins() @@ -137,6 +134,130 @@ class TestListPlugins: assert result[0]['debug'] is True +class TestPluginDiagnostics: + @pytest.mark.asyncio + async def test_emit_event_preserves_response_sources(self): + connector = create_mock_connector() + query = text_query('hello') + event = query.message_event + object.__setattr__(event, 'query', query) + connector_module = get_connector_module() + original_from_event = connector_module.context.EventContext.from_event + original_model_validate = connector_module.context.EventContext.model_validate + response_sources = [ + { + 'kind': 'reply_message_chain', + 'plugin': {'author': 'tester', 'name': 'demo'}, + } + ] + + async def emit_event_response(event_context, include_plugins=None): + return { + 'event_context': event_context, + 'emitted_plugins': [], + 'response_sources': response_sources, + } + + connector.handler = AsyncMock() + connector.handler.emit_event = AsyncMock(side_effect=emit_event_response) + + fake_event_ctx = Mock() + event_dump = event.model_dump() + event_dump['event_name'] = 'FriendMessage' + fake_event_ctx.model_dump.return_value = { + 'query_id': query.query_id, + 'eid': 0, + 'event_name': 'FriendMessage', + 'event': event_dump, + 'is_prevent_default': False, + 'is_prevent_postorder': False, + } + connector_module.context.EventContext.from_event = Mock(return_value=fake_event_ctx) + parsed_event_ctx = Mock() + connector_module.context.EventContext.model_validate = Mock(return_value=parsed_event_ctx) + try: + event_ctx = await connector.emit_event(event) + finally: + connector_module.context.EventContext.from_event = original_from_event + connector_module.context.EventContext.model_validate = original_model_validate + + assert event_ctx is parsed_event_ctx + assert event_ctx._response_sources == response_sources + + @pytest.mark.asyncio + async def test_emit_event_leaves_response_sources_absent_for_old_runtime(self): + connector = create_mock_connector() + query = text_query('hello') + event = query.message_event + object.__setattr__(event, 'query', query) + connector_module = get_connector_module() + original_from_event = connector_module.context.EventContext.from_event + original_model_validate = connector_module.context.EventContext.model_validate + + async def emit_event_response(event_context, include_plugins=None): + return { + 'event_context': event_context, + 'emitted_plugins': [ + {'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}}, + ], + } + + connector.handler = AsyncMock() + connector.handler.emit_event = AsyncMock(side_effect=emit_event_response) + + fake_event_ctx = Mock() + event_dump = event.model_dump() + event_dump['event_name'] = 'FriendMessage' + fake_event_ctx.model_dump.return_value = { + 'query_id': query.query_id, + 'eid': 0, + 'event_name': 'FriendMessage', + 'event': event_dump, + 'is_prevent_default': False, + 'is_prevent_postorder': False, + } + connector_module.context.EventContext.from_event = Mock(return_value=fake_event_ctx) + parsed_event_ctx = Mock() + connector_module.context.EventContext.model_validate = Mock(return_value=parsed_event_ctx) + try: + event_ctx = await connector.emit_event(event) + finally: + connector_module.context.EventContext.from_event = original_from_event + connector_module.context.EventContext.model_validate = original_model_validate + + assert '_response_sources' not in vars(event_ctx) + assert event_ctx._emitted_plugins == [ + {'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}}, + ] + + @pytest.mark.asyncio + async def test_notify_plugin_diagnostic_skips_when_disabled(self): + connector_module = get_connector_module() + + async def mock_disconnect(conn): + pass + + mock_app = create_mock_app() + mock_app.instance_config.data = {'plugin': {'enable': False}} + connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect) + connector.handler = AsyncMock() + + await connector.notify_plugin_diagnostic({'code': 'response_delivery_failed'}) + + connector.handler.notify_plugin_diagnostic.assert_not_called() + + @pytest.mark.asyncio + async def test_notify_plugin_diagnostic_is_best_effort(self): + connector = create_mock_connector() + connector.handler = AsyncMock() + connector.handler.notify_plugin_diagnostic = AsyncMock(side_effect=RuntimeError('action not found')) + + await connector.notify_plugin_diagnostic({'code': 'response_delivery_failed'}) + + connector.handler.notify_plugin_diagnostic.assert_awaited_once() + connector.ap.logger.debug.assert_called_once() + + class TestListKnowledgeEngines: """Tests for list_knowledge_engines method.""" @@ -230,7 +351,8 @@ class TestCallParser: ) connector.handler.parse_document.assert_called_once_with( - 'author', 'parser', + 'author', + 'parser', {'mime_type': 'text/plain', 'filename': 'test.txt'}, b'file content', ) @@ -251,9 +373,7 @@ class TestRAGMethods: result = await connector.call_rag_ingest('author/engine', {'file': 'test.pdf'}) - connector.handler.rag_ingest_document.assert_called_once_with( - 'author', 'engine', {'file': 'test.pdf'} - ) + connector.handler.rag_ingest_document.assert_called_once_with('author', 'engine', {'file': 'test.pdf'}) assert result['status'] == 'success' @pytest.mark.asyncio @@ -264,14 +384,16 @@ class TestRAGMethods: connector.handler = AsyncMock() connector.handler.retrieve_knowledge = AsyncMock( - return_value={'results': [{'id': 'doc1', 'content': [{'type': 'text', 'text': 'test'}], 'metadata': {}, 'distance': 0.1}]} + return_value={ + 'results': [ + {'id': 'doc1', 'content': [{'type': 'text', 'text': 'test'}], 'metadata': {}, 'distance': 0.1} + ] + } ) result = await connector.call_rag_retrieve('author/engine', {'query': 'test'}) - connector.handler.retrieve_knowledge.assert_called_once_with( - 'author', 'engine', '', {'query': 'test'} - ) + connector.handler.retrieve_knowledge.assert_called_once_with('author', 'engine', '', {'query': 'test'}) assert result == { 'results': [ { @@ -290,9 +412,7 @@ class TestRAGMethods: connector = create_mock_connector() connector.handler = AsyncMock() - connector.handler.get_rag_creation_schema = AsyncMock( - return_value={'properties': {'name': {'type': 'string'}}} - ) + connector.handler.get_rag_creation_schema = AsyncMock(return_value={'properties': {'name': {'type': 'string'}}}) result = await connector.get_rag_creation_schema('author/engine') @@ -326,9 +446,7 @@ class TestRAGMethods: await connector.rag_on_kb_create('author/engine', 'kb-uuid', {'model': 'test'}) - connector.handler.rag_on_kb_create.assert_called_once_with( - 'author', 'engine', 'kb-uuid', {'model': 'test'} - ) + connector.handler.rag_on_kb_create.assert_called_once_with('author', 'engine', 'kb-uuid', {'model': 'test'}) @pytest.mark.asyncio async def test_rag_on_kb_delete(self): @@ -354,9 +472,7 @@ class TestRAGMethods: result = await connector.call_rag_delete_document('author/engine', 'doc-uuid', 'kb-uuid') - connector.handler.rag_delete_document.assert_called_once_with( - 'author', 'engine', 'doc-uuid', 'kb-uuid' - ) + connector.handler.rag_delete_document.assert_called_once_with('author', 'engine', 'doc-uuid', 'kb-uuid') assert result is True @@ -446,9 +562,7 @@ class TestGetPluginInfo: connector = create_mock_connector() connector.handler = AsyncMock() - connector.handler.get_plugin_info = AsyncMock( - return_value={'manifest': {'metadata': {'name': 'plugin'}}} - ) + connector.handler.get_plugin_info = AsyncMock(return_value={'manifest': {'metadata': {'name': 'plugin'}}}) result = await connector.get_plugin_info('author', 'plugin') @@ -470,9 +584,7 @@ class TestSetPluginConfig: await connector.set_plugin_config('author', 'plugin', {'setting': 'value'}) - connector.handler.set_plugin_config.assert_called_once_with( - 'author', 'plugin', {'setting': 'value'} - ) + connector.handler.set_plugin_config.assert_called_once_with('author', 'plugin', {'setting': 'value'}) class TestPingPluginRuntime: diff --git a/tests/unit_tests/plugin/test_connector_static.py b/tests/unit_tests/plugin/test_connector_static.py index 77747b7b8..8c88b9707 100644 --- a/tests/unit_tests/plugin/test_connector_static.py +++ b/tests/unit_tests/plugin/test_connector_static.py @@ -3,6 +3,7 @@ Tests cover: - _parse_plugin_id() parsing and validation """ + from __future__ import annotations import pytest diff --git a/tests/unit_tests/plugin/test_extract_deps.py b/tests/unit_tests/plugin/test_extract_deps.py index e9c30ec99..0980f4cc3 100644 --- a/tests/unit_tests/plugin/test_extract_deps.py +++ b/tests/unit_tests/plugin/test_extract_deps.py @@ -6,6 +6,7 @@ Tests cover: - Handling missing requirements.txt - Handling empty/malformed requirements.txt """ + from __future__ import annotations import zipfile @@ -82,13 +83,13 @@ class TestExtractDepsMetadata: """Test that comments and empty lines are filtered.""" connector_instance = create_mock_connector() - requirements = '''# This is a comment + requirements = """# This is a comment requests>=2.0 # Another comment flask==1.0 -numpy''' +numpy""" zip_bytes = create_zip_with_requirements(requirements) task_context = Mock() @@ -147,9 +148,9 @@ numpy''' """Test handling requirements.txt with only comments.""" connector_instance = create_mock_connector() - requirements = '''# Comment 1 + requirements = """# Comment 1 # Comment 2 -# Comment 3''' +# Comment 3""" zip_bytes = create_zip_with_requirements(requirements) task_context = Mock() diff --git a/tests/unit_tests/plugin/test_handler.py b/tests/unit_tests/plugin/test_handler.py index 44522ef46..a2fdddd33 100644 --- a/tests/unit_tests/plugin/test_handler.py +++ b/tests/unit_tests/plugin/test_handler.py @@ -40,11 +40,13 @@ class TestHandlerQueryVariables: """Test set_query_var returns error when query not found.""" runtime_handler = make_handler(mock_app) - response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]({ - 'query_id': 'nonexistent-query', - 'key': 'test_var', - 'value': 'test_value', - }) + response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]( + { + 'query_id': 'nonexistent-query', + 'key': 'test_var', + 'value': 'test_value', + } + ) assert response.code != 0 assert 'nonexistent-query' in response.message @@ -58,11 +60,13 @@ class TestHandlerQueryVariables: mock_app.query_pool.cached_queries['test-query'] = mock_query - response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]({ - 'query_id': 'test-query', - 'key': 'test_var', - 'value': 'test_value', - }) + response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]( + { + 'query_id': 'test-query', + 'key': 'test_var', + 'value': 'test_value', + } + ) assert response.code == 0 assert mock_query.variables['test_var'] == 'test_value' @@ -76,10 +80,12 @@ class TestHandlerQueryVariables: mock_app.query_pool.cached_queries['test-query'] = mock_query - response = await runtime_handler.actions[PluginToRuntimeAction.GET_QUERY_VAR.value]({ - 'query_id': 'test-query', - 'key': 'existing_var', - }) + response = await runtime_handler.actions[PluginToRuntimeAction.GET_QUERY_VAR.value]( + { + 'query_id': 'test-query', + 'key': 'existing_var', + } + ) assert response.code == 0 assert response.data == {'value': 'existing_value'} @@ -93,9 +99,11 @@ class TestHandlerQueryVariables: mock_app.query_pool.cached_queries['test-query'] = mock_query - response = await runtime_handler.actions[PluginToRuntimeAction.GET_QUERY_VARS.value]({ - 'query_id': 'test-query', - }) + response = await runtime_handler.actions[PluginToRuntimeAction.GET_QUERY_VARS.value]( + { + 'query_id': 'test-query', + } + ) assert response.code == 0 assert response.data == {'vars': mock_query.variables} @@ -108,7 +116,7 @@ class TestHandlerRagErrorResponse: """Test basic error response creation.""" from langbot.pkg.plugin.handler import _make_rag_error_response - error = Exception("test error") + error = Exception('test error') response = _make_rag_error_response(error, 'TestError') # ActionResponse is a pydantic model, check message field @@ -120,13 +128,8 @@ class TestHandlerRagErrorResponse: """Test error response with extra context.""" from langbot.pkg.plugin.handler import _make_rag_error_response - error = ValueError("invalid input") - response = _make_rag_error_response( - error, - 'ValidationError', - field='name', - value='test' - ) + error = ValueError('invalid input') + response = _make_rag_error_response(error, 'ValidationError', field='name', value='test') assert 'ValidationError' in response.message assert 'field=name' in response.message @@ -137,7 +140,7 @@ class TestHandlerRagErrorResponse: """Test error response includes exception type.""" from langbot.pkg.plugin.handler import _make_rag_error_response - error = RuntimeError("connection failed") + error = RuntimeError('connection failed') response = _make_rag_error_response(error, 'ConnectionError') assert 'RuntimeError' in response.message @@ -148,7 +151,7 @@ class TestHandlerRagErrorResponse: """Test error response with no extra context.""" from langbot.pkg.plugin.handler import _make_rag_error_response - error = KeyError("missing_key") + error = KeyError('missing_key') response = _make_rag_error_response(error, 'LookupError') # No context parts means no brackets @@ -156,6 +159,36 @@ class TestHandlerRagErrorResponse: assert 'KeyError' in response.message +class TestHandlerPluginDiagnostic: + @pytest.mark.asyncio + async def test_notify_plugin_diagnostic_falls_back_to_raw_protocol_action(self): + """Diagnostic forwarding works before the SDK enum exists.""" + app = SimpleNamespace() + app.logger = SimpleNamespace(debug=MagicMock()) + runtime_handler = make_handler(app) + runtime_handler.call_action = AsyncMock(return_value={}) + + payload = {'code': 'response_delivery_failed'} + await runtime_handler.notify_plugin_diagnostic(payload) + + action = runtime_handler.call_action.await_args.args[0] + assert action.value == 'plugin_diagnostic' + assert runtime_handler.call_action.await_args.args[1] is payload + assert runtime_handler.call_action.await_args.kwargs['timeout'] == 5 + + def test_langbot_to_runtime_action_uses_enum_when_available(self): + """The compatibility helper should prefer SDK enums once available.""" + from langbot.pkg.plugin import handler as plugin_handler + + sentinel = object() + original = plugin_handler.LangBotToRuntimeAction + plugin_handler.LangBotToRuntimeAction = SimpleNamespace(PLUGIN_DIAGNOSTIC=sentinel) + try: + assert plugin_handler._langbot_to_runtime_action('PLUGIN_DIAGNOSTIC', 'plugin_diagnostic') is sentinel + finally: + plugin_handler.LangBotToRuntimeAction = original + + class TestConstantsSemanticVersion: """Tests for version constant access.""" diff --git a/tests/unit_tests/plugin/test_handler_actions.py b/tests/unit_tests/plugin/test_handler_actions.py index 81bc75705..2dbc1f4f9 100644 --- a/tests/unit_tests/plugin/test_handler_actions.py +++ b/tests/unit_tests/plugin/test_handler_actions.py @@ -27,6 +27,68 @@ def compiled_params(statement): return statement.compile().params +class TestRagRerankAction: + """Tests for RAG rerank action handler.""" + + @pytest.fixture + def app(self): + mock_app = Mock() + mock_app.model_mgr = Mock() + mock_app.logger = Mock() + return mock_app + + @pytest.mark.asyncio + async def test_invokes_rerank_model_and_sorts_scores(self, app): + """Rerank action uses the selected model and returns top scores.""" + provider = Mock() + provider.invoke_rerank = AsyncMock( + return_value=[ + {'index': 0, 'relevance_score': 0.2}, + {'index': 1, 'relevance_score': 0.9}, + ] + ) + rerank_model = SimpleNamespace(provider=provider) + app.model_mgr.get_rerank_model_by_uuid = AsyncMock(return_value=rerank_model) + runtime_handler = make_handler(app) + + response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value]( + { + 'rerank_model_uuid': 'rerank-1', + 'query': 'hello', + 'documents': ['a', 'b'], + 'top_k': 1, + 'extra_args': {'return_documents': False}, + } + ) + + assert response.code == 0 + assert response.data['results'] == [{'index': 1, 'relevance_score': 0.9}] + app.model_mgr.get_rerank_model_by_uuid.assert_awaited_once_with('rerank-1') + provider.invoke_rerank.assert_awaited_once_with( + model=rerank_model, + query='hello', + documents=['a', 'b'], + extra_args={'return_documents': False}, + ) + + @pytest.mark.asyncio + async def test_returns_error_when_rerank_model_missing(self, app): + """Missing rerank model returns an action error.""" + app.model_mgr.get_rerank_model_by_uuid = AsyncMock(side_effect=ValueError('not found')) + runtime_handler = make_handler(app) + + response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value]( + { + 'rerank_model_uuid': 'missing', + 'query': 'hello', + 'documents': ['a'], + } + ) + + assert response.code != 0 + assert 'Rerank model with rerank_model_uuid missing not found' in response.message + + class TestInitializePluginSettings: """Tests for initialize_plugin_settings action handler.""" @@ -47,12 +109,14 @@ class TestInitializePluginSettings: Mock(), ] - response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value]({ - 'plugin_author': 'test-author', - 'plugin_name': 'test-plugin', - 'install_source': 'local', - 'install_info': {'path': '/test'}, - }) + response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value]( + { + 'plugin_author': 'test-author', + 'plugin_name': 'test-plugin', + 'install_source': 'local', + 'install_info': {'path': '/test'}, + } + ) assert response.code == 0 assert app.persistence_mgr.execute_async.await_count == 2 @@ -82,12 +146,14 @@ class TestInitializePluginSettings: Mock(), ] - response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value]({ - 'plugin_author': 'test-author', - 'plugin_name': 'test-plugin', - 'install_source': 'github', - 'install_info': {'repo': 'author/name'}, - }) + response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value]( + { + 'plugin_author': 'test-author', + 'plugin_name': 'test-plugin', + 'install_source': 'github', + 'install_info': {'repo': 'author/name'}, + } + ) assert response.code == 0 assert app.persistence_mgr.execute_async.await_count == 3 @@ -161,9 +227,7 @@ class TestSetBinaryStorage: runtime_handler = make_handler(app) app.persistence_mgr.execute_async.return_value = make_result(SimpleNamespace(value=b'old')) - response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value]( - self.payload(b'new') - ) + response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new')) assert response.code == 0 assert app.persistence_mgr.execute_async.await_count == 2 @@ -203,9 +267,7 @@ class TestSetBinaryStorage: runtime_handler = make_handler(app) app.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = 0 - response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value]( - self.payload(b'x') - ) + response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'x')) assert response.code != 0 assert '1 > 0 bytes' in response.message @@ -228,10 +290,12 @@ class TestGetPluginSettings: runtime_handler = make_handler(app) app.persistence_mgr.execute_async.return_value = make_result() - response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value]({ - 'plugin_author': 'test-author', - 'plugin_name': 'test-plugin', - }) + response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value]( + { + 'plugin_author': 'test-author', + 'plugin_name': 'test-plugin', + } + ) assert response.code == 0 assert response.data == { @@ -255,10 +319,12 @@ class TestGetPluginSettings: ) app.persistence_mgr.execute_async.return_value = make_result(setting) - response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value]({ - 'plugin_author': 'test-author', - 'plugin_name': 'test-plugin', - }) + response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value]( + { + 'plugin_author': 'test-author', + 'plugin_name': 'test-plugin', + } + ) assert response.code == 0 assert response.data == { @@ -286,11 +352,13 @@ class TestGetBinaryStorage: runtime_handler = make_handler(app) app.persistence_mgr.execute_async.return_value = make_result(SimpleNamespace(value=b'test binary content')) - response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value]({ - 'key': 'test-key', - 'owner_type': 'plugin', - 'owner': 'test-owner', - }) + response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value]( + { + 'key': 'test-key', + 'owner_type': 'plugin', + 'owner': 'test-owner', + } + ) assert response.code == 0 assert response.data == { @@ -303,11 +371,13 @@ class TestGetBinaryStorage: runtime_handler = make_handler(app) app.persistence_mgr.execute_async.return_value = make_result() - response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value]({ - 'key': 'test-key', - 'owner_type': 'plugin', - 'owner': 'test-owner', - }) + response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value]( + { + 'key': 'test-key', + 'owner_type': 'plugin', + 'owner': 'test-owner', + } + ) assert response.code != 0 assert 'Storage with key test-key not found' in response.message @@ -329,9 +399,11 @@ class TestHandlerQueryLookup: """Query-bound actions return error when query_id is not cached.""" runtime_handler = make_handler(app) - response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value]({ - 'query_id': 'nonexistent-query', - }) + response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value]( + { + 'query_id': 'nonexistent-query', + } + ) assert response.code != 0 assert 'nonexistent-query' in response.message @@ -343,9 +415,11 @@ class TestHandlerQueryLookup: query = SimpleNamespace(variables={}, bot_uuid='test-bot-uuid') app.query_pool.cached_queries['existing-query'] = query - response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value]({ - 'query_id': 'existing-query', - }) + response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value]( + { + 'query_id': 'existing-query', + } + ) assert response.code == 0 assert response.data == {'bot_uuid': 'test-bot-uuid'} diff --git a/tests/unit_tests/plugin/test_handler_helpers.py b/tests/unit_tests/plugin/test_handler_helpers.py index 81bbe010e..dc86b7a5f 100644 --- a/tests/unit_tests/plugin/test_handler_helpers.py +++ b/tests/unit_tests/plugin/test_handler_helpers.py @@ -4,6 +4,7 @@ Tests cover: - _make_rag_error_response() helper function - RuntimeConnectionHandler cleanup_plugin_data method """ + from __future__ import annotations import pytest @@ -23,7 +24,7 @@ class TestMakeRagErrorResponse: """Test basic error response creation.""" handler = get_handler_module() - error = ValueError("test error message") + error = ValueError('test error message') result = handler._make_rag_error_response(error, 'TestError') # ActionResponse.error() returns code=1 (error status) @@ -36,7 +37,7 @@ class TestMakeRagErrorResponse: """Test that error type is included in message.""" handler = get_handler_module() - error = RuntimeError("something went wrong") + error = RuntimeError('something went wrong') result = handler._make_rag_error_response(error, 'VectorStoreError') assert '[VectorStoreError/RuntimeError]' in result.message @@ -45,7 +46,7 @@ class TestMakeRagErrorResponse: """Test that extra context fields are included.""" handler = get_handler_module() - error = Exception("embedding failed") + error = Exception('embedding failed') result = handler._make_rag_error_response( error, 'EmbeddingError', @@ -71,7 +72,7 @@ class TestMakeRagErrorResponse: """Test multiple context fields are comma separated.""" handler = get_handler_module() - error = IOError("file not found") + error = IOError('file not found') result = handler._make_rag_error_response( error, 'FileServiceError', @@ -119,9 +120,7 @@ class TestCleanupPluginData: handler_instance = Mock(spec=handler_module.RuntimeConnectionHandler) handler_instance.ap = mock_app - await handler_module.RuntimeConnectionHandler.cleanup_plugin_data( - handler_instance, 'author', 'plugin-name' - ) + await handler_module.RuntimeConnectionHandler.cleanup_plugin_data(handler_instance, 'author', 'plugin-name') # Should have at least 2 calls: one for settings, one for binary storage - assert mock_app.persistence_mgr.execute_async.call_count >= 2 \ No newline at end of file + assert mock_app.persistence_mgr.execute_async.call_count >= 2 diff --git a/tests/unit_tests/plugin/test_plugin_id_parsing.py b/tests/unit_tests/plugin/test_plugin_id_parsing.py index c6d479fbc..f76463785 100644 --- a/tests/unit_tests/plugin/test_plugin_id_parsing.py +++ b/tests/unit_tests/plugin/test_plugin_id_parsing.py @@ -2,7 +2,7 @@ import pytest -from src.langbot.pkg.plugin.connector import PluginRuntimeConnector +from langbot.pkg.plugin.connector import PluginRuntimeConnector def test_parse_plugin_id_accepts_author_name(): diff --git a/tests/unit_tests/provider/__init__.py b/tests/unit_tests/provider/__init__.py index 8b1378917..758036b7e 100644 --- a/tests/unit_tests/provider/__init__.py +++ b/tests/unit_tests/provider/__init__.py @@ -1 +1 @@ - +"""Provider requester tests""" diff --git a/tests/unit_tests/provider/conftest.py b/tests/unit_tests/provider/conftest.py index 71dd5cd89..13b44fd14 100644 --- a/tests/unit_tests/provider/conftest.py +++ b/tests/unit_tests/provider/conftest.py @@ -88,7 +88,10 @@ class AnotherFakeRequester(requester.ProviderAPIRequester): async def invoke_llm(self, query, model, messages, funcs=None, extra_args={}, remove_think=False): import langbot_plugin.api.entities.builtin.provider.message as provider_message - return provider_message.Message(role='assistant', content=[provider_message.ContentElement(type='text', text='Another response')]) + + return provider_message.Message( + role='assistant', content=[provider_message.ContentElement(type='text', text='Another response')] + ) async def invoke_rerank(self, model, query: str, documents: list, extra_args={}): """Return fake rerank results.""" @@ -135,8 +138,10 @@ def mock_app_for_modelmgr(): # Fake persistence manager - returns empty results by default app.persistence_mgr = SimpleNamespace() + async def default_execute(query): return _make_mock_result([]) + app.persistence_mgr.execute_async = AsyncMock(side_effect=default_execute) # Fake discover engine @@ -165,9 +170,7 @@ def fake_requester_registry(mock_app_for_modelmgr): fake_component = _create_fake_component('fake-requester', FakeProviderAPIRequester) another_component = _create_fake_component('another-fake-requester', AnotherFakeRequester) - app.discover.get_components_by_kind = Mock( - return_value=[fake_component, another_component] - ) + app.discover.get_components_by_kind = Mock(return_value=[fake_component, another_component]) model_mgr = ModelManager(app) return model_mgr diff --git a/tests/unit_tests/provider/requesters/test_anthropic_requester.py b/tests/unit_tests/provider/requesters/test_anthropic_requester.py deleted file mode 100644 index 54abb615c..000000000 --- a/tests/unit_tests/provider/requesters/test_anthropic_requester.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Tests for AnthropicMessages requester. - -Tests config and pure utility methods. -""" - -from __future__ import annotations - -from unittest.mock import MagicMock - - -class TestAnthropicMessagesConfig: - """Tests for default config.""" - - def test_default_config_values(self): - """Check default_config.""" - from langbot.pkg.provider.modelmgr.requesters.anthropicmsgs import AnthropicMessages - - assert AnthropicMessages.default_config['base_url'] == 'https://api.anthropic.com' - assert AnthropicMessages.default_config['timeout'] == 120 - - def test_config_override(self): - """Config can override defaults.""" - from langbot.pkg.provider.modelmgr.requesters.anthropicmsgs import AnthropicMessages - - mock_app = MagicMock() - req = AnthropicMessages(mock_app, { - 'base_url': 'https://custom.anthropic.com', - 'timeout': 60, - }) - - assert req.requester_cfg['base_url'] == 'https://custom.anthropic.com' - assert req.requester_cfg['timeout'] == 60 \ No newline at end of file diff --git a/tests/unit_tests/provider/requesters/test_chatcmpl_errors_direct.py b/tests/unit_tests/provider/requesters/test_chatcmpl_errors_direct.py deleted file mode 100644 index c51476c27..000000000 --- a/tests/unit_tests/provider/requesters/test_chatcmpl_errors_direct.py +++ /dev/null @@ -1,247 +0,0 @@ -"""Tests for requester error handling - direct import version. - -Tests error handling branches by importing real packages and mocking -only the necessary dependencies. -""" - -from __future__ import annotations - -import asyncio -from unittest.mock import AsyncMock, MagicMock -import pytest -import openai # Import real openai package - -from langbot.pkg.provider.modelmgr.errors import RequesterError - - -class TestInvokeLLMErrorHandling: - """Tests for invoke_llm error handling branches.""" - - @pytest.fixture - def mock_app(self): - """Create mock Application.""" - app = MagicMock() - app.tool_mgr = MagicMock() - app.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=[]) - return app - - @pytest.fixture - def mock_model(self): - """Create mock RuntimeLLMModel.""" - model = MagicMock() - model.model_entity = MagicMock() - model.model_entity.name = 'gpt-4' - model.provider = MagicMock() - model.provider.token_mgr = MagicMock() - model.provider.token_mgr.get_token = MagicMock(return_value='test-key') - return model - - @pytest.fixture - def mock_message(self): - """Create mock provider message.""" - msg = MagicMock() - msg.dict = MagicMock(return_value={'role': 'user', 'content': 'test'}) - return msg - - @pytest.fixture - def requester_with_mocked_client(self, mock_app): - """Create requester with mocked OpenAI client.""" - from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions - - req = OpenAIChatCompletions(mock_app, { - 'base_url': 'https://api.openai.com/v1', - 'timeout': 120, - }) - - # Replace client with mock - req.client = MagicMock() - req.client.chat = MagicMock() - req.client.chat.completions = MagicMock() - req.client.chat.completions.create = AsyncMock() - - return req - - @pytest.mark.asyncio - async def test_timeout_error(self, requester_with_mocked_client, mock_model, mock_message): - """TimeoutError is wrapped as RequesterError.""" - requester_with_mocked_client.client.chat.completions.create = AsyncMock( - side_effect=asyncio.TimeoutError() - ) - - with pytest.raises(RequesterError) as exc: - await requester_with_mocked_client.invoke_llm( - query=None, - model=mock_model, - messages=[mock_message], - ) - - assert '超时' in str(exc.value) - - @pytest.mark.asyncio - async def test_bad_request_context_length(self, requester_with_mocked_client, mock_model, mock_message): - """BadRequestError with context_length_exceeded has special message.""" - error = openai.BadRequestError( - message='context_length_exceeded: max 4096', - response=MagicMock(status_code=400), - body={} - ) - requester_with_mocked_client.client.chat.completions.create = AsyncMock( - side_effect=error - ) - - with pytest.raises(RequesterError) as exc: - await requester_with_mocked_client.invoke_llm( - query=None, - model=mock_model, - messages=[mock_message], - ) - - assert '上文过长' in str(exc.value) - - @pytest.mark.asyncio - async def test_authentication_error(self, requester_with_mocked_client, mock_model, mock_message): - """AuthenticationError shows invalid api-key message.""" - error = openai.AuthenticationError( - message='Invalid API key', - response=MagicMock(status_code=401), - body={} - ) - requester_with_mocked_client.client.chat.completions.create = AsyncMock( - side_effect=error - ) - - with pytest.raises(RequesterError) as exc: - await requester_with_mocked_client.invoke_llm( - query=None, - model=mock_model, - messages=[mock_message], - ) - - assert 'api-key' in str(exc.value).lower() or '无效' in str(exc.value) - - @pytest.mark.asyncio - async def test_rate_limit_error(self, requester_with_mocked_client, mock_model, mock_message): - """RateLimitError shows rate limit message.""" - error = openai.RateLimitError( - message='Rate limit exceeded', - response=MagicMock(status_code=429), - body={} - ) - requester_with_mocked_client.client.chat.completions.create = AsyncMock( - side_effect=error - ) - - with pytest.raises(RequesterError) as exc: - await requester_with_mocked_client.invoke_llm( - query=None, - model=mock_model, - messages=[mock_message], - ) - - assert '频繁' in str(exc.value) or '余额' in str(exc.value) - - -class TestInvokeEmbeddingErrorHandling: - """Tests for invoke_embedding error handling.""" - - @pytest.fixture - def mock_app(self): - return MagicMock() - - @pytest.fixture - def mock_embedding_model(self): - model = MagicMock() - model.model_entity = MagicMock() - model.model_entity.name = 'text-embedding-ada-002' - model.model_entity.extra_args = {} - model.provider = MagicMock() - model.provider.token_mgr = MagicMock() - model.provider.token_mgr.get_token = MagicMock(return_value='test-key') - return model - - @pytest.fixture - def requester_with_mocked_client(self, mock_app): - from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions - - req = OpenAIChatCompletions(mock_app, {}) - req.client = MagicMock() - req.client.embeddings = MagicMock() - req.client.embeddings.create = AsyncMock() - - return req - - @pytest.mark.asyncio - async def test_embedding_timeout_error(self, requester_with_mocked_client, mock_embedding_model): - """TimeoutError in embedding request.""" - requester_with_mocked_client.client.embeddings.create = AsyncMock( - side_effect=asyncio.TimeoutError() - ) - - with pytest.raises(RequesterError) as exc: - await requester_with_mocked_client.invoke_embedding( - model=mock_embedding_model, - input_text=['test'], - ) - - assert '超时' in str(exc.value) - - @pytest.mark.asyncio - async def test_embedding_bad_request_error(self, requester_with_mocked_client, mock_embedding_model): - """BadRequestError in embedding request.""" - error = openai.BadRequestError( - message='Invalid model', - response=MagicMock(status_code=400), - body={} - ) - requester_with_mocked_client.client.embeddings.create = AsyncMock( - side_effect=error - ) - - with pytest.raises(RequesterError) as exc: - await requester_with_mocked_client.invoke_embedding( - model=mock_embedding_model, - input_text=['test'], - ) - - assert '参数' in str(exc.value) - - -class TestRequesterErrorClass: - """Tests for RequesterError.""" - - def test_error_message_prefix(self): - """RequesterError has '模型请求失败' prefix.""" - from langbot.pkg.provider.modelmgr.errors import RequesterError - - error = RequesterError('test error') - assert '模型请求失败' in str(error) - - def test_error_is_exception(self): - """RequesterError inherits Exception.""" - from langbot.pkg.provider.modelmgr.errors import RequesterError - - error = RequesterError('test') - assert isinstance(error, Exception) - - -class TestDefaultConfig: - """Tests for requester default config.""" - - def test_default_config(self): - """Check default_config values.""" - from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions - - assert OpenAIChatCompletions.default_config['base_url'] == 'https://api.openai.com/v1' - assert OpenAIChatCompletions.default_config['timeout'] == 120 - - def test_config_override(self): - """Config overrides defaults.""" - from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions - - req = OpenAIChatCompletions(MagicMock(), { - 'base_url': 'https://custom.com/v1', - 'timeout': 60, - }) - - assert req.requester_cfg['base_url'] == 'https://custom.com/v1' - assert req.requester_cfg['timeout'] == 60 diff --git a/tests/unit_tests/provider/requesters/test_chatcmpl_utils.py b/tests/unit_tests/provider/requesters/test_chatcmpl_utils.py deleted file mode 100644 index 38d9df1c1..000000000 --- a/tests/unit_tests/provider/requesters/test_chatcmpl_utils.py +++ /dev/null @@ -1,340 +0,0 @@ -"""Tests for requester pure utility functions. - -Tests the helper methods in OpenAIChatCompletions that don't require network calls. -""" - -from __future__ import annotations - -from unittest.mock import MagicMock - -from tests.utils.import_isolation import isolated_sys_modules - - -class TestMaskApiKey: - """Tests for _mask_api_key method.""" - - def _create_requester_with_mocks(self): - """Create requester instance with mocked dependencies.""" - mocks = { - 'langbot.pkg.core.app': MagicMock(), - 'langbot_plugin.api.entities.builtin.resource.tool': MagicMock(), - 'langbot_plugin.api.entities.builtin.pipeline.query': MagicMock(), - 'langbot_plugin.api.entities.builtin.provider.message': MagicMock(), - 'langbot.pkg.provider.modelmgr.errors': MagicMock(), - } - - with isolated_sys_modules(mocks): - from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions - - mock_app = MagicMock() - requester = OpenAIChatCompletions(mock_app, {}) - return requester - - def test_mask_api_key_full(self): - """Mask a full API key.""" - requester = self._create_requester_with_mocks() - - result = requester._mask_api_key('sk-1234567890abcdef') - assert result == 'sk-1...cdef' - - def test_mask_api_key_short(self): - """Mask a short API key (<=8 chars).""" - requester = self._create_requester_with_mocks() - - result = requester._mask_api_key('short') - assert result == '****' - - def test_mask_api_key_empty(self): - """Empty API key returns empty string.""" - requester = self._create_requester_with_mocks() - - result = requester._mask_api_key('') - assert result == '' - - def test_mask_api_key_none(self): - """None API key returns empty string.""" - requester = self._create_requester_with_mocks() - - result = requester._mask_api_key(None) - assert result == '' - - def test_mask_api_key_exact_8_chars(self): - """API key with exactly 8 chars is masked as **** (<=8 threshold).""" - requester = self._create_requester_with_mocks() - - result = requester._mask_api_key('12345678') - assert result == '****' # <= 8 chars gets masked - - -class TestInferModelType: - """Tests for _infer_model_type method.""" - - def _create_requester_with_mocks(self): - mocks = { - 'langbot.pkg.core.app': MagicMock(), - 'langbot_plugin.api.entities.builtin.resource.tool': MagicMock(), - 'langbot_plugin.api.entities.builtin.pipeline.query': MagicMock(), - 'langbot_plugin.api.entities.builtin.provider.message': MagicMock(), - 'langbot.pkg.provider.modelmgr.errors': MagicMock(), - } - - with isolated_sys_modules(mocks): - from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions - - mock_app = MagicMock() - requester = OpenAIChatCompletions(mock_app, {}) - return requester - - def test_infer_embedding_from_name(self): - """Infer embedding type from model name.""" - requester = self._create_requester_with_mocks() - - assert requester._infer_model_type('text-embedding-ada-002') == 'embedding' - assert requester._infer_model_type('bge-large-en') == 'embedding' - assert requester._infer_model_type('e5-base') == 'embedding' - assert requester._infer_model_type('m3e-base') == 'embedding' - - def test_infer_llm_from_name(self): - """Infer LLM type from model name.""" - requester = self._create_requester_with_mocks() - - assert requester._infer_model_type('gpt-4') == 'llm' - assert requester._infer_model_type('claude-3-opus') == 'llm' - assert requester._infer_model_type('llama-2-70b') == 'llm' - - def test_infer_model_type_none_id(self): - """Handle None model_id.""" - requester = self._create_requester_with_mocks() - - result = requester._infer_model_type(None) - assert result == 'llm' # Default - - def test_infer_model_type_empty_id(self): - """Handle empty model_id.""" - requester = self._create_requester_with_mocks() - - result = requester._infer_model_type('') - assert result == 'llm' # Default - - -class TestNormalizeModalities: - """Tests for _normalize_modalities method.""" - - def _create_requester_with_mocks(self): - mocks = { - 'langbot.pkg.core.app': MagicMock(), - 'langbot_plugin.api.entities.builtin.resource.tool': MagicMock(), - 'langbot_plugin.api.entities.builtin.pipeline.query': MagicMock(), - 'langbot_plugin.api.entities.builtin.provider.message': MagicMock(), - 'langbot.pkg.provider.modelmgr.errors': MagicMock(), - } - - with isolated_sys_modules(mocks): - from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions - - mock_app = MagicMock() - requester = OpenAIChatCompletions(mock_app, {}) - return requester - - def test_normalize_string_modality(self): - """Normalize single string modality.""" - requester = self._create_requester_with_mocks() - - result = requester._normalize_modalities('text,image') - assert result == ['text', 'image'] - - def test_normalize_list_modalities(self): - """Normalize list of modalities.""" - requester = self._create_requester_with_mocks() - - result = requester._normalize_modalities(['text', 'image', 'audio']) - assert result == ['text', 'image', 'audio'] - - def test_normalize_dict_modalities(self): - """Normalize dict with nested modalities.""" - requester = self._create_requester_with_mocks() - - result = requester._normalize_modalities({'input': ['text'], 'output': ['text', 'image']}) - assert result == ['text', 'image'] - - def test_normalize_none(self): - """Handle None input.""" - requester = self._create_requester_with_mocks() - - result = requester._normalize_modalities(None) - assert result == [] - - def test_normalize_arrow_separator(self): - """Handle arrow separator in modality string.""" - requester = self._create_requester_with_mocks() - - result = requester._normalize_modalities('text->image') - assert result == ['text', 'image'] - - -class TestParseRerankResponse: - """Tests for _parse_rerank_response static method.""" - - def test_parse_cohere_jina_format(self): - """Parse Cohere/Jina/SiliconFlow format.""" - from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions - - data = { - 'results': [ - {'index': 0, 'relevance_score': 0.95}, - {'index': 1, 'relevance_score': 0.80}, - ] - } - - result = OpenAIChatCompletions._parse_rerank_response(data) - assert result == [ - {'index': 0, 'relevance_score': 0.95}, - {'index': 1, 'relevance_score': 0.80}, - ] - - def test_parse_voyage_format(self): - """Parse Voyage AI format.""" - from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions - - data = { - 'data': [ - {'index': 0, 'relevance_score': 0.90}, - {'index': 2, 'relevance_score': 0.75}, - ] - } - - result = OpenAIChatCompletions._parse_rerank_response(data) - assert result == [ - {'index': 0, 'relevance_score': 0.90}, - {'index': 2, 'relevance_score': 0.75}, - ] - - def test_parse_dashscope_format(self): - """Parse DashScope format.""" - from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions - - data = { - 'output': { - 'results': [ - {'index': 0, 'relevance_score': 0.85}, - ] - } - } - - result = OpenAIChatCompletions._parse_rerank_response(data) - assert result == [{'index': 0, 'relevance_score': 0.85}] - - def test_parse_unknown_format(self): - """Handle unknown format returns empty list.""" - from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions - - data = {'unknown_key': 'value'} - - result = OpenAIChatCompletions._parse_rerank_response(data) - assert result == [] - - def test_parse_empty_results(self): - """Handle empty results.""" - from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions - - data = {'results': []} - - result = OpenAIChatCompletions._parse_rerank_response(data) - assert result == [] - - -class TestExtractScanMetadata: - """Tests for _extract_scan_metadata method.""" - - def _create_requester_with_mocks(self): - mocks = { - 'langbot.pkg.core.app': MagicMock(), - 'langbot_plugin.api.entities.builtin.resource.tool': MagicMock(), - 'langbot_plugin.api.entities.builtin.pipeline.query': MagicMock(), - 'langbot_plugin.api.entities.builtin.provider.message': MagicMock(), - 'langbot.pkg.provider.modelmgr.errors': MagicMock(), - } - - with isolated_sys_modules(mocks): - from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions - - mock_app = MagicMock() - requester = OpenAIChatCompletions(mock_app, {}) - return requester - - def test_extract_basic_metadata(self): - """Extract basic model metadata.""" - requester = self._create_requester_with_mocks() - - item = { - 'id': 'gpt-4', - 'name': 'GPT-4 Turbo', - 'description': 'Most capable GPT-4 model', - 'context_length': 128000, - 'owned_by': 'openai', - } - - result = requester._extract_scan_metadata(item, 'gpt-4') - - assert result['display_name'] == 'GPT-4 Turbo' - assert result['description'] == 'Most capable GPT-4 model' - assert result['context_length'] == 128000 - assert result['owned_by'] == 'openai' - - def test_extract_metadata_missing_fields(self): - """Handle missing metadata fields.""" - requester = self._create_requester_with_mocks() - - item = {'id': 'unknown-model'} - - result = requester._extract_scan_metadata(item, 'unknown-model') - - assert result['display_name'] is None - assert result['description'] is None - assert result['context_length'] is None - assert result['owned_by'] is None - - def test_extract_metadata_top_provider_context(self): - """Extract context_length from top_provider.""" - requester = self._create_requester_with_mocks() - - item = { - 'id': 'model', - 'top_provider': { - 'context_length': 4096, - }, - } - - result = requester._extract_scan_metadata(item, 'model') - - assert result['context_length'] == 4096 - - def test_extract_metadata_empty_strings(self): - """Handle empty string values.""" - requester = self._create_requester_with_mocks() - - item = { - 'id': 'model', - 'name': '', # Empty name - 'description': ' ', # Whitespace only - 'owned_by': '', - } - - result = requester._extract_scan_metadata(item, 'model') - - assert result['display_name'] is None - assert result['description'] is None - assert result['owned_by'] is None - - def test_extract_metadata_name_matches_id(self): - """When name equals id, display_name is None.""" - requester = self._create_requester_with_mocks() - - item = { - 'id': 'gpt-4', - 'name': 'gpt-4', # Same as id - } - - result = requester._extract_scan_metadata(item, 'gpt-4') - - assert result['display_name'] is None diff --git a/tests/unit_tests/provider/requesters/test_ollama_requester.py b/tests/unit_tests/provider/requesters/test_ollama_requester.py deleted file mode 100644 index 993115ab3..000000000 --- a/tests/unit_tests/provider/requesters/test_ollama_requester.py +++ /dev/null @@ -1,264 +0,0 @@ -"""Tests for OllamaChatCompletions requester. - -Tests model inference, payload construction, and error handling. -""" - -from __future__ import annotations - -import asyncio -from unittest.mock import AsyncMock, MagicMock -import pytest - -from langbot.pkg.provider.modelmgr.errors import RequesterError - - -class TestOllamaRequesterConfig: - """Tests for default config.""" - - def test_default_config_values(self): - """Check default_config.""" - from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions - - assert OllamaChatCompletions.default_config['base_url'] == 'http://127.0.0.1:11434' - assert OllamaChatCompletions.default_config['timeout'] == 120 - - def test_config_override(self): - """Config can override defaults.""" - from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions - - mock_app = MagicMock() - req = OllamaChatCompletions(mock_app, { - 'base_url': 'http://custom.ollama:11434', - 'timeout': 300, - }) - - assert req.requester_cfg['base_url'] == 'http://custom.ollama:11434' - assert req.requester_cfg['timeout'] == 300 - - -class TestOllamaInferModelType: - """Tests for _infer_model_type pure function.""" - - @pytest.fixture - def requester(self): - from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions - - return OllamaChatCompletions(MagicMock(), {}) - - def test_infer_embedding_from_name(self, requester): - """Embedding keywords return 'embedding'.""" - assert requester._infer_model_type('nomic-embed-text') == 'embedding' - assert requester._infer_model_type('bge-large') == 'embedding' - assert requester._infer_model_type('text-embedding') == 'embedding' - - def test_infer_llm_from_name(self, requester): - """Non-embedding keywords return 'llm'.""" - assert requester._infer_model_type('llama2') == 'llm' - assert requester._infer_model_type('mistral') == 'llm' - assert requester._infer_model_type('codellama') == 'llm' - - def test_infer_model_type_none(self, requester): - """None model_id returns 'llm'.""" - assert requester._infer_model_type(None) == 'llm' - - def test_infer_model_type_empty(self, requester): - """Empty model_id returns 'llm'.""" - assert requester._infer_model_type('') == 'llm' - - -class TestOllamaInferModelAbilities: - """Tests for _infer_model_abilities pure function.""" - - @pytest.fixture - def requester(self): - from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions - - return OllamaChatCompletions(MagicMock(), {}) - - def test_infer_vision_ability(self, requester): - """Vision keywords add 'vision' ability.""" - item = { - 'details': { - 'family': 'llava', - } - } - - abilities = requester._infer_model_abilities(item, 'llava-v1.5') - assert 'vision' in abilities - - def test_infer_vision_from_model_id(self, requester): - """Vision keywords in model_id add 'vision' ability.""" - item = {} - abilities = requester._infer_model_abilities(item, 'llava-7b') - assert 'vision' in abilities - - def test_infer_func_call_ability(self, requester): - """Tool/function keywords add 'func_call' ability.""" - item = { - 'details': { - 'families': ['tools'], - } - } - - abilities = requester._infer_model_abilities(item, 'model') - assert 'func_call' in abilities - - def test_infer_no_abilities(self, requester): - """No matching keywords returns empty abilities.""" - item = { - 'details': { - 'family': 'llama', - } - } - - abilities = requester._infer_model_abilities(item, 'llama-2') - assert len(abilities) == 0 - - def test_infer_multiple_abilities(self, requester): - """Multiple keywords can add multiple abilities.""" - item = { - 'details': { - 'family': 'vision', - 'families': ['tools'], - } - } - - abilities = requester._infer_model_abilities(item, 'vision-tool-model') - assert 'vision' in abilities - assert 'func_call' in abilities - - -class TestOllamaMakeMessage: - """Tests for _make_msg response parsing.""" - - @pytest.fixture - def requester(self): - from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions - - return OllamaChatCompletions(MagicMock(), {}) - - def _create_ollama_response(self, content, tool_calls=None): - """Helper to create mock ollama response.""" - import ollama - - mock_response = MagicMock(spec=ollama.ChatResponse) - mock_message = MagicMock(spec=ollama.Message) - mock_message.content = content - mock_message.tool_calls = tool_calls - mock_response.message = mock_message - - return mock_response - - @pytest.mark.asyncio - async def test_make_msg_text_content(self, requester): - """Text content is extracted.""" - mock_response = self._create_ollama_response('Hello world') - - result = await requester._make_msg(mock_response) - - assert result.content == 'Hello world' - assert result.role == 'assistant' - - @pytest.mark.asyncio - async def test_make_msg_with_tool_calls(self, requester): - """Tool calls are parsed.""" - mock_tool_call = MagicMock() - mock_tool_call.function = MagicMock() - mock_tool_call.function.name = 'get_weather' - mock_tool_call.function.arguments = {'location': 'Beijing'} - - mock_response = self._create_ollama_response('', tool_calls=[mock_tool_call]) - - result = await requester._make_msg(mock_response) - - assert result.tool_calls is not None - assert len(result.tool_calls) == 1 - assert result.tool_calls[0].function.name == 'get_weather' - # Arguments should be JSON string - assert isinstance(result.tool_calls[0].function.arguments, str) - - @pytest.mark.asyncio - async def test_make_msg_empty_message_raises(self, requester): - """Empty message raises ValueError.""" - mock_response = MagicMock() - mock_response.message = None - - with pytest.raises(ValueError, match='message'): - await requester._make_msg(mock_response) - - -class TestOllamaErrorHandling: - """Tests for error handling branches.""" - - @pytest.fixture - def mock_app(self): - app = MagicMock() - app.tool_mgr = MagicMock() - app.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=[]) - return app - - @pytest.fixture - def requester_with_mocked_client(self, mock_app): - from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions - - req = OllamaChatCompletions(mock_app, {}) - req.client = MagicMock() - req.client.chat = AsyncMock() - - return req - - @pytest.fixture - def mock_model(self): - model = MagicMock() - model.model_entity = MagicMock() - model.model_entity.name = 'llama2' - model.provider = MagicMock() - model.provider.token_mgr = MagicMock() - model.provider.token_mgr.get_token = MagicMock(return_value='') - return model - - @pytest.fixture - def mock_message(self): - msg = MagicMock() - msg.role = 'user' - msg.content = 'test' - msg.dict = MagicMock(return_value={'role': 'user', 'content': 'test'}) - return msg - - @pytest.mark.asyncio - async def test_timeout_error(self, requester_with_mocked_client, mock_model, mock_message): - """TimeoutError is converted to RequesterError.""" - requester_with_mocked_client.client.chat = AsyncMock(side_effect=asyncio.TimeoutError()) - - with pytest.raises(RequesterError) as exc: - await requester_with_mocked_client.invoke_llm( - query=None, - model=mock_model, - messages=[mock_message], - ) - - assert '超时' in str(exc.value) - - -class TestOllamaScanModels: - """Tests for scan_models method.""" - - @pytest.fixture - def mock_app(self): - return MagicMock() - - @pytest.fixture - def requester(self, mock_app): - from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions - - req = OllamaChatCompletions(mock_app, { - 'base_url': 'http://127.0.0.1:11434', - 'timeout': 120, - }) - return req - - def test_requester_name_constant(self): - """REQUESTER_NAME constant exists.""" - from langbot.pkg.provider.modelmgr.requesters.ollamachat import REQUESTER_NAME - - assert REQUESTER_NAME == 'ollama-chat' diff --git a/tests/unit_tests/provider/test_litellm_convert_messages.py b/tests/unit_tests/provider/test_litellm_convert_messages.py new file mode 100644 index 000000000..87ad2e027 --- /dev/null +++ b/tests/unit_tests/provider/test_litellm_convert_messages.py @@ -0,0 +1,93 @@ +"""Unit tests for LiteLLMRequester._convert_messages. + +Focus: the content-part normalization that (a) converts image_base64 parts to +the OpenAI image_url shape and (b) drops non-image file parts (file_base64 / +file_url) which OpenAI-compatible chat models reject. The latter is essential +for Voice/File attachments — including ones replayed from conversation history — +since the agent consumes their bytes via the sandbox, not the model payload. +""" + +import langbot_plugin.api.entities.builtin.provider.message as provider_message + +from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester + + +def _make_requester() -> LiteLLMRequester: + # _convert_messages does not touch instance config, so bypass __init__. + return LiteLLMRequester.__new__(LiteLLMRequester) + + +def test_convert_messages_drops_file_base64_part(): + req = _make_requester() + msg = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('analyze this audio'), + provider_message.ContentElement.from_file_base64('data:audio/wav;base64,AAAA', 'voice.wav'), + ], + ) + out = req._convert_messages([msg]) + parts = out[0]['content'] + types = [p.get('type') for p in parts] + assert 'file_base64' not in types + assert types == ['text'] + assert parts[0]['text'] == 'analyze this audio' + + +def test_convert_messages_drops_file_url_part(): + req = _make_requester() + msg = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('here is a doc'), + provider_message.ContentElement.from_file_url('http://example.com/report.xlsx', 'report.xlsx'), + ], + ) + out = req._convert_messages([msg]) + types = [p.get('type') for p in out[0]['content']] + assert types == ['text'] + + +def test_convert_messages_keeps_image_and_converts_to_image_url(): + req = _make_requester() + msg = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('look'), + provider_message.ContentElement.from_image_base64('data:image/png;base64,AAAA'), + ], + ) + out = req._convert_messages([msg]) + parts = out[0]['content'] + types = [p.get('type') for p in parts] + # image is preserved and reshaped to the OpenAI image_url form + assert types == ['text', 'image_url'] + img_part = parts[1] + assert img_part['image_url'] == {'url': 'data:image/png;base64,AAAA'} + assert 'image_base64' not in img_part + + +def test_convert_messages_mixed_history_strips_only_files(): + req = _make_requester() + # Simulate replayed history: an old voice turn + a current text turn. + history_voice = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('old audio turn'), + provider_message.ContentElement.from_file_base64('data:audio/wav;base64,BBBB', 'voice.wav'), + ], + ) + current = provider_message.Message( + role='user', + content=[provider_message.ContentElement.from_text('now do the csv')], + ) + out = req._convert_messages([history_voice, current]) + assert [p.get('type') for p in out[0]['content']] == ['text'] + assert [p.get('type') for p in out[1]['content']] == ['text'] + + +def test_convert_messages_plain_string_content_untouched(): + req = _make_requester() + msg = provider_message.Message(role='user', content='just text') + out = req._convert_messages([msg]) + assert out[0]['content'] == 'just text' diff --git a/tests/unit_tests/provider/test_litellmchat.py b/tests/unit_tests/provider/test_litellmchat.py new file mode 100644 index 000000000..f7a448ab6 --- /dev/null +++ b/tests/unit_tests/provider/test_litellmchat.py @@ -0,0 +1,1277 @@ +""" +Tests for LiteLLMRequester - unified requester for chat, embedding, and rerank. + +These tests verify: +- Parameter building and LiteLLM API calls +- Response processing and usage extraction +- Error handling and exception translation +- Model name building with provider prefix +""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch + +import litellm + +from langbot.pkg.provider.modelmgr.requesters import litellmchat +from langbot.pkg.provider.modelmgr import errors + + +class MockRuntimeModel: + """Mock RuntimeLLMModel for testing""" + + def __init__(self, model_name: str = 'gpt-4o', api_key: str = 'test-key'): + self.model_entity = Mock() + self.model_entity.name = model_name + self.model_entity.extra_args = {} + self.provider = Mock() + self.provider.token_mgr = Mock() + self.provider.token_mgr.get_token = Mock(return_value=api_key) + + +class MockRuntimeEmbeddingModel: + """Mock RuntimeEmbeddingModel for testing""" + + def __init__(self, model_name: str = 'text-embedding-3-small', api_key: str = 'test-key'): + self.model_entity = Mock() + self.model_entity.name = model_name + self.model_entity.extra_args = {} + self.provider = Mock() + self.provider.token_mgr = Mock() + self.provider.token_mgr.get_token = Mock(return_value=api_key) + + +class MockRuntimeRerankModel: + """Mock RuntimeRerankModel for testing""" + + def __init__(self, model_name: str = 'cohere/rerank-english-v3.0', api_key: str = 'test-key'): + self.model_entity = Mock() + self.model_entity.name = model_name + self.model_entity.extra_args = {} + self.provider = Mock() + self.provider.token_mgr = Mock() + self.provider.token_mgr.get_token = Mock(return_value=api_key) + + +class TestBuildLiteLLMModelName: + """Test _build_litellm_model_name method""" + + def test_no_provider_prefix(self): + """Test model name without provider prefix""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={'custom_llm_provider': ''}) + result = requester._build_litellm_model_name('gpt-4o') + assert result == 'gpt-4o' + + def test_with_provider_prefix(self): + """Test model name with provider prefix""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={'custom_llm_provider': 'openai'}) + result = requester._build_litellm_model_name('gpt-4o') + assert result == 'openai/gpt-4o' + + def test_avoid_duplicate_provider_prefix(self): + """Test model name with an existing matching provider prefix.""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={'custom_llm_provider': 'openai'}) + result = requester._build_litellm_model_name('openai/gpt-4o') + assert result == 'openai/gpt-4o' + + def test_override_provider(self): + """Test override provider via parameter""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={'custom_llm_provider': 'openai'}) + result = requester._build_litellm_model_name('claude-3', custom_llm_provider='anthropic') + assert result == 'anthropic/claude-3' + + +class TestExtractUsage: + """Test _extract_usage method""" + + def test_extract_usage_with_data(self): + """Test extraction with valid usage data""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + response = Mock() + response.usage = Mock() + response.usage.prompt_tokens = 100 + response.usage.completion_tokens = 50 + response.usage.total_tokens = 150 + + result = requester._extract_usage(response) + + assert result['prompt_tokens'] == 100 + assert result['completion_tokens'] == 50 + assert result['total_tokens'] == 150 + + def test_extract_usage_with_zero_values(self): + """Test extraction when values are 0""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + response = Mock() + response.usage = Mock() + response.usage.prompt_tokens = 0 + response.usage.completion_tokens = 0 + response.usage.total_tokens = 0 + + result = requester._extract_usage(response) + + assert result['prompt_tokens'] == 0 + assert result['completion_tokens'] == 0 + + def test_extract_usage_without_provider_usage(self): + """Missing provider usage is not treated as authoritative zero usage.""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + response = Mock() + response.usage = None + + assert requester._extract_usage(response) is None + + +class TestNormalizeUsage: + """Test _normalize_usage helper covering real-world usage shapes""" + + def test_none_usage(self): + """None usage -> all zeros (no crash)""" + result = litellmchat.LiteLLMRequester._normalize_usage(None) + assert result == {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0} + + def test_dict_usage(self): + """Usage given as a plain dict""" + result = litellmchat.LiteLLMRequester._normalize_usage( + {'prompt_tokens': 12, 'completion_tokens': 8, 'total_tokens': 20} + ) + assert result == {'prompt_tokens': 12, 'completion_tokens': 8, 'total_tokens': 20} + + def test_preserves_token_details(self): + """Provider token details such as cache counters are preserved.""" + result = litellmchat.LiteLLMRequester._normalize_usage( + { + 'prompt_tokens': 12, + 'completion_tokens': 8, + 'total_tokens': 20, + 'prompt_tokens_details': {'cached_tokens': 7}, + 'completion_tokens_details': {'reasoning_tokens': 3}, + } + ) + + assert result['prompt_tokens'] == 12 + assert result['prompt_tokens_details'] == {'cached_tokens': 7} + assert result['completion_tokens_details'] == {'reasoning_tokens': 3} + + def test_missing_total_is_derived(self): + """When total_tokens is absent/zero it is derived from prompt + completion""" + usage = Mock() + usage.prompt_tokens = 42 + usage.completion_tokens = 10 + usage.total_tokens = 0 + result = litellmchat.LiteLLMRequester._normalize_usage(usage) + assert result['total_tokens'] == 52 + + def test_partial_attrs_default_to_zero(self): + """Missing attributes default to 0 instead of raising""" + usage = Mock(spec=['prompt_tokens']) + usage.prompt_tokens = 5 + result = litellmchat.LiteLLMRequester._normalize_usage(usage) + assert result == {'prompt_tokens': 5, 'completion_tokens': 0, 'total_tokens': 5} + + +class TestInvokeLLMStreamUsage: + """Regression tests for streaming token usage capture. + + Real OpenAI-compatible gateways (e.g. new-api) send the final usage payload + in a chunk that still carries a (empty-delta) choice rather than an empty + `choices` list. The usage must be captured regardless, otherwise streamed + calls record 0 tokens. + """ + + def _make_chunk(self, *, content=None, tool_calls=None, finish_reason=None, usage=None, has_choice=True): + chunk = Mock() + if usage is not None: + chunk.usage = usage + else: + chunk.usage = None + if has_choice: + choice = Mock() + delta = Mock() + delta.model_dump = Mock(return_value={'role': 'assistant', 'content': content, 'tool_calls': tool_calls}) + choice.delta = delta + choice.finish_reason = finish_reason + chunk.choices = [choice] + else: + chunk.choices = [] + return chunk + + @pytest.mark.asyncio + async def test_stream_usage_with_nonempty_choices(self): + """Usage chunk that still has a choice must populate _stream_usage.""" + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + import langbot_plugin.api.entities.builtin.provider.message as provider_message + + mock_ap = Mock() + mock_ap.tool_mgr = Mock() + mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None) + requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={}) + model = MockRuntimeModel('gpt-4o', 'test-api-key') + + usage = Mock() + usage.prompt_tokens = 24 + usage.completion_tokens = 48 + usage.total_tokens = 72 + + chunks = [ + self._make_chunk(content='Hello'), + self._make_chunk(content=None, finish_reason='stop'), + # Final usage chunk WITH a non-empty (empty-delta) choice — the bug case. + self._make_chunk(content=None, usage=usage, has_choice=True), + ] + + async def _aiter(*args, **kwargs): + for c in chunks: + yield c + + query = Mock(spec=pipeline_query.Query) + query.variables = {} + + messages = [provider_message.Message(role='user', content='Hi')] + + with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())): + collected = [] + async for ch in requester.invoke_llm_stream(query=query, model=model, messages=messages): + collected.append(ch) + + assert '_stream_usage' in query.variables + assert query.variables['_stream_usage']['prompt_tokens'] == 24 + assert query.variables['_stream_usage']['completion_tokens'] == 48 + assert query.variables['_stream_usage']['total_tokens'] == 72 + + @pytest.mark.asyncio + async def test_stream_usage_with_empty_choices(self): + """Usage chunk with empty choices list must also populate _stream_usage.""" + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + import langbot_plugin.api.entities.builtin.provider.message as provider_message + + mock_ap = Mock() + mock_ap.tool_mgr = Mock() + mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None) + requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={}) + model = MockRuntimeModel('gpt-4o', 'test-api-key') + + usage = Mock() + usage.prompt_tokens = 5 + usage.completion_tokens = 7 + usage.total_tokens = 12 + + chunks = [ + self._make_chunk(content='Hi there'), + self._make_chunk(content=None, finish_reason='stop'), + self._make_chunk(usage=usage, has_choice=False), + ] + + async def _aiter(*args, **kwargs): + for c in chunks: + yield c + + query = Mock(spec=pipeline_query.Query) + query.variables = {} + messages = [provider_message.Message(role='user', content='Hi')] + + with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())): + async for _ in requester.invoke_llm_stream(query=query, model=model, messages=messages): + pass + + assert query.variables['_stream_usage']['total_tokens'] == 12 + + @pytest.mark.asyncio + async def test_stream_tool_call_delta_missing_id_and_name(self): + """LiteLLM may stream tool-call argument deltas with id/name set to None.""" + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + import langbot_plugin.api.entities.builtin.provider.message as provider_message + + mock_ap = Mock() + mock_ap.tool_mgr = Mock() + mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock( + return_value=[{'type': 'function', 'function': {'name': 'qa_plugin_echo'}}] + ) + requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={}) + model = MockRuntimeModel('gpt-4o', 'test-api-key') + + chunks = [ + self._make_chunk( + tool_calls=[ + { + 'index': 0, + 'id': 'call_123', + 'type': 'function', + 'function': {'name': 'qa_plugin_echo', 'arguments': ''}, + } + ] + ), + self._make_chunk( + tool_calls=[ + { + 'index': 0, + 'id': None, + 'type': None, + 'function': {'name': None, 'arguments': '{"text":'}, + } + ] + ), + self._make_chunk( + tool_calls=[ + { + 'index': 0, + 'function': {'arguments': '"plugin-tool-ok"}'}, + } + ] + ), + self._make_chunk(finish_reason='tool_calls'), + ] + + async def _aiter(*args, **kwargs): + for c in chunks: + yield c + + query = Mock(spec=pipeline_query.Query) + query.variables = {} + messages = [provider_message.Message(role='user', content='Call the tool')] + funcs = [Mock()] + + with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())): + collected = [ + chunk + async for chunk in requester.invoke_llm_stream( + query=query, + model=model, + messages=messages, + funcs=funcs, + ) + ] + + tool_chunks = [chunk for chunk in collected if chunk.tool_calls] + assert len(tool_chunks) == 3 + assert tool_chunks[1].tool_calls[0].id == 'call_123' + assert tool_chunks[1].tool_calls[0].function.name == 'qa_plugin_echo' + assert tool_chunks[1].tool_calls[0].function.arguments == '{"text":' + assert tool_chunks[2].tool_calls[0].function.arguments == '"plugin-tool-ok"}' + + @pytest.mark.asyncio + async def test_stream_tool_call_without_id_is_not_dropped(self): + """Regression for #2261. + + Ollama's OpenAI-compatible streaming endpoint emits a tool-call delta + carrying an ``index`` and a ``function`` payload but never an + OpenAI-style ``id``. The requester used to drop any id-less tool call, + so a tool-only turn yielded nothing, the stream "completed" with 0 + chars, and the chat got stuck. A stable per-index id must be + synthesized so the tool call survives. + """ + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + import langbot_plugin.api.entities.builtin.provider.message as provider_message + + mock_ap = Mock() + mock_ap.tool_mgr = Mock() + mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock( + return_value=[{'type': 'function', 'function': {'name': 'zotero_search_items'}}] + ) + requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={'custom_llm_provider': 'openai'}) + model = MockRuntimeModel('gpt-oss:20b', 'ollama') + + # Ollama delivers the whole tool call in a single delta, with no id. + chunks = [ + self._make_chunk( + tool_calls=[ + { + 'index': 0, + 'function': {'name': 'zotero_search_items', 'arguments': '{"query":"hello"}'}, + } + ] + ), + self._make_chunk(finish_reason='tool_calls'), + ] + + async def _aiter(*args, **kwargs): + for c in chunks: + yield c + + query = Mock(spec=pipeline_query.Query) + query.variables = {} + messages = [provider_message.Message(role='user', content='hello?')] + funcs = [Mock()] + + with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())): + collected = [ + chunk + async for chunk in requester.invoke_llm_stream( + query=query, + model=model, + messages=messages, + funcs=funcs, + ) + ] + + tool_chunks = [chunk for chunk in collected if chunk.tool_calls] + assert len(tool_chunks) == 1, 'id-less Ollama tool call must not be dropped' + tc = tool_chunks[0].tool_calls[0] + assert tc.id == 'call_0' + assert tc.function.name == 'zotero_search_items' + assert tc.function.arguments == '{"query":"hello"}' + + @pytest.mark.asyncio + async def test_stream_multiple_tool_calls_without_id_get_distinct_ids(self): + """Two parallel id-less tool calls must keep distinct synthesized ids.""" + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + import langbot_plugin.api.entities.builtin.provider.message as provider_message + + mock_ap = Mock() + mock_ap.tool_mgr = Mock() + mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock( + return_value=[{'type': 'function', 'function': {'name': 'zotero_search_items'}}] + ) + requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={'custom_llm_provider': 'openai'}) + model = MockRuntimeModel('gpt-oss:20b', 'ollama') + + chunks = [ + self._make_chunk( + tool_calls=[ + {'index': 0, 'function': {'name': 'zotero_search_items', 'arguments': '{"q":"a"}'}}, + {'index': 1, 'function': {'name': 'zotero_get_notes', 'arguments': '{"q":"b"}'}}, + ] + ), + self._make_chunk(finish_reason='tool_calls'), + ] + + async def _aiter(*args, **kwargs): + for c in chunks: + yield c + + query = Mock(spec=pipeline_query.Query) + query.variables = {} + messages = [provider_message.Message(role='user', content='hello?')] + funcs = [Mock()] + + with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())): + collected = [ + chunk + async for chunk in requester.invoke_llm_stream( + query=query, + model=model, + messages=messages, + funcs=funcs, + ) + ] + + tool_chunks = [chunk for chunk in collected if chunk.tool_calls] + assert len(tool_chunks) == 1 + ids = {tc.id for tc in tool_chunks[0].tool_calls} + assert ids == {'call_0', 'call_1'} + + +class TestProcessThinkingContent: + """Test _process_thinking_content method""" + + def test_no_thinking_markers(self): + """Test content without thinking markers""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + result = requester._process_thinking_content('Hello world', None, remove_think=True) + assert result == 'Hello world' + + def test_remove_thinking_markers(self): + """Test removing thinking markers when remove_think=True""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + content = 'CRETIRE_REASONING_BEGINkLet me think...CRETIRE_REASONING_ENDk The answer is 42.' + result = requester._process_thinking_content(content, None, remove_think=True) + assert result == 'The answer is 42.' + + def test_preserve_thinking_markers(self): + """Test preserving thinking markers when remove_think=False""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + content = 'CRETIRE_REASONING_BEGINkLet me think...CRETIRE_REASONING_ENDk The answer is 42.' + result = requester._process_thinking_content(content, None, remove_think=False) + assert 'CRETIRE_REASONING_BEGINk' in result + assert 'The answer is 42.' in result + + def test_empty_content(self): + """Test empty content""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + result = requester._process_thinking_content('', None, remove_think=True) + assert result == '' + + +class TestBuildCommonArgs: + """Test _build_common_args method""" + + def test_build_args_with_all_params(self): + """Test building args with all config params""" + requester = litellmchat.LiteLLMRequester( + ap=Mock(), + config={ + 'base_url': 'https://api.openai.com/v1', + 'timeout': 60, + 'drop_params': True, + 'num_retries': 3, + 'api_version': '2024-01-01', + }, + ) + + args = {} + requester._build_common_args(args) + + assert args['api_base'] == 'https://api.openai.com/v1' + assert args['timeout'] == 60 + assert args['drop_params'] == True + assert args['num_retries'] == 3 + assert args['api_version'] == '2024-01-01' + + def test_build_args_without_retry_params(self): + """Test building args without retry params for embedding/rerank""" + requester = litellmchat.LiteLLMRequester( + ap=Mock(), + config={ + 'base_url': 'https://api.openai.com/v1', + 'timeout': 60, + 'num_retries': 3, + }, + ) + + args = {} + requester._build_common_args(args, include_retry_params=False) + + assert args['api_base'] == 'https://api.openai.com/v1' + assert args['timeout'] == 60 + assert 'num_retries' not in args + + +class TestHandleLiteLLMError: + """Test _handle_litellm_error method""" + + def test_bad_request_error(self): + """Test BadRequestError translation""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + # Create proper LiteLLM exception with required args + error = litellm.BadRequestError(message='test error', model='gpt-4o', llm_provider='openai') + + with pytest.raises(errors.RequesterError) as exc_info: + requester._handle_litellm_error(error) + + assert '请求参数错误' in str(exc_info.value) + + def test_authentication_error(self): + """Test AuthenticationError translation""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + error = litellm.AuthenticationError(message='invalid key', model='gpt-4o', llm_provider='openai') + + with pytest.raises(errors.RequesterError) as exc_info: + requester._handle_litellm_error(error) + + assert 'API key 无效' in str(exc_info.value) + + def test_rate_limit_error(self): + """Test RateLimitError translation""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + error = litellm.RateLimitError(message='rate limited', model='gpt-4o', llm_provider='openai') + + with pytest.raises(errors.RequesterError) as exc_info: + requester._handle_litellm_error(error) + + assert '请求过于频繁' in str(exc_info.value) + + def test_timeout_error(self): + """Test Timeout translation""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + error = litellm.Timeout(message='timeout', model='gpt-4o', llm_provider='openai') + + with pytest.raises(errors.RequesterError) as exc_info: + requester._handle_litellm_error(error) + + assert '请求超时' in str(exc_info.value) + + def test_context_window_error(self): + """Test ContextWindowExceededError translation""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + error = litellm.ContextWindowExceededError(message='context too long', model='gpt-4o', llm_provider='openai') + + with pytest.raises(errors.RequesterError) as exc_info: + requester._handle_litellm_error(error) + + assert '上下文长度超限' in str(exc_info.value) + + def test_unknown_error(self): + """Test unknown error translation""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + with pytest.raises(errors.RequesterError) as exc_info: + requester._handle_litellm_error(Exception('unknown')) + + assert '未知错误' in str(exc_info.value) + + +class TestInvokeLLM: + """Test invoke_llm method""" + + @pytest.mark.asyncio + async def test_invoke_llm_basic(self): + """Test basic LLM invocation""" + mock_ap = Mock() + mock_ap.tool_mgr = Mock() + mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None) + + requester = litellmchat.LiteLLMRequester( + ap=mock_ap, + config={ + 'base_url': 'https://api.openai.com/v1', + 'timeout': 60, + }, + ) + + model = MockRuntimeModel('gpt-4o', 'test-api-key') + + # Mock LiteLLM response + mock_response = Mock() + mock_response.choices = [Mock()] + mock_response.choices[0].message = Mock() + mock_response.choices[0].message.model_dump = Mock( + return_value={ + 'role': 'assistant', + 'content': 'Hello! How can I help you?', + } + ) + mock_response.usage = Mock() + mock_response.usage.prompt_tokens = 10 + mock_response.usage.completion_tokens = 20 + mock_response.usage.total_tokens = 30 + + import langbot_plugin.api.entities.builtin.provider.message as provider_message + + messages = [provider_message.Message(role='user', content='Hello')] + + # Patch acompletion at the import location + with patch.object(litellmchat, 'acompletion', new_callable=AsyncMock, return_value=mock_response): + result_msg, usage = await requester.invoke_llm( + query=None, + model=model, + messages=messages, + ) + + assert result_msg.role == 'assistant' + assert result_msg.content == 'Hello! How can I help you?' + assert usage['prompt_tokens'] == 10 + assert usage['completion_tokens'] == 20 + + @pytest.mark.asyncio + async def test_invoke_llm_with_tools(self): + """Test LLM invocation with function calling""" + mock_ap = Mock() + mock_ap.tool_mgr = Mock() + mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock( + return_value=[{'type': 'function', 'function': {'name': 'get_weather'}}] + ) + + requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={}) + + model = MockRuntimeModel('gpt-4o', 'test-api-key') + + mock_response = Mock() + mock_response.choices = [Mock()] + mock_response.choices[0].message = Mock() + mock_response.choices[0].message.model_dump = Mock( + return_value={ + 'role': 'assistant', + 'content': None, + 'tool_calls': [ + {'id': 'call_123', 'type': 'function', 'function': {'name': 'get_weather', 'arguments': '{}'}} + ], + } + ) + mock_response.usage = Mock() + mock_response.usage.prompt_tokens = 15 + mock_response.usage.completion_tokens = 10 + mock_response.usage.total_tokens = 25 + + import langbot_plugin.api.entities.builtin.resource.tool as resource_tool + import langbot_plugin.api.entities.builtin.provider.message as provider_message + + messages = [provider_message.Message(role='user', content='What is the weather?')] + # Create proper LLMTool with all required fields + funcs = [Mock(spec=resource_tool.LLMTool)] + funcs[0].name = 'get_weather' + funcs[0].description = 'Get weather' + + with patch.object(litellmchat, 'acompletion', new_callable=AsyncMock, return_value=mock_response): + result_msg, usage = await requester.invoke_llm( + query=None, + model=model, + messages=messages, + funcs=funcs, + ) + + assert result_msg.tool_calls is not None + called_kwargs = litellmchat.acompletion.await_args.kwargs + assert called_kwargs['tools'] == [{'type': 'function', 'function': {'name': 'get_weather'}}] + assert called_kwargs['tool_choice'] == 'auto' + + @pytest.mark.asyncio + async def test_build_completion_args_preserves_explicit_tool_choice(self): + """Model extra args can override the default auto tool choice.""" + mock_ap = Mock() + mock_ap.tool_mgr = Mock() + mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock( + return_value=[{'type': 'function', 'function': {'name': 'get_weather'}}] + ) + + requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={}) + model = MockRuntimeModel('gpt-4o', 'test-api-key') + model.model_entity.extra_args = {'tool_choice': 'required'} + + import langbot_plugin.api.entities.builtin.resource.tool as resource_tool + import langbot_plugin.api.entities.builtin.provider.message as provider_message + + funcs = [Mock(spec=resource_tool.LLMTool)] + messages = [provider_message.Message(role='user', content='What is the weather?')] + + args = await requester._build_completion_args(model, messages, funcs) + + assert args['tool_choice'] == 'required' + + @pytest.mark.asyncio + async def test_invoke_llm_error_handling(self): + """Test LLM invocation error handling""" + mock_ap = Mock() + mock_ap.tool_mgr = Mock() + mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None) + + requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={}) + + model = MockRuntimeModel('gpt-4o', 'test-api-key') + + import langbot_plugin.api.entities.builtin.provider.message as provider_message + + messages = [provider_message.Message(role='user', content='Hello')] + + error = litellm.AuthenticationError(message='invalid key', model='gpt-4o', llm_provider='openai') + + with patch.object(litellmchat, 'acompletion', new_callable=AsyncMock, side_effect=error): + with pytest.raises(errors.RequesterError) as exc_info: + await requester.invoke_llm( + query=None, + model=model, + messages=messages, + ) + + assert 'API key 无效' in str(exc_info.value) + + +class TestInvokeEmbedding: + """Test invoke_embedding method""" + + @pytest.mark.asyncio + async def test_invoke_embedding_basic(self): + """Test basic embedding invocation""" + requester = litellmchat.LiteLLMRequester( + ap=Mock(), + config={ + 'base_url': 'https://api.openai.com/v1', + }, + ) + + model = MockRuntimeEmbeddingModel('text-embedding-3-small', 'test-api-key') + + # Mock LiteLLM embedding response + mock_response = Mock() + mock_response.data = [ + Mock(embedding=[0.1, 0.2, 0.3]), + Mock(embedding=[0.4, 0.5, 0.6]), + ] + mock_response.usage = Mock() + mock_response.usage.prompt_tokens = 20 + mock_response.usage.completion_tokens = 0 + mock_response.usage.total_tokens = 20 + + with patch.object(litellmchat, 'aembedding', new_callable=AsyncMock, return_value=mock_response): + embeddings, usage = await requester.invoke_embedding( + model=model, + input_text=['Hello', 'World'], + ) + + assert len(embeddings) == 2 + assert embeddings[0] == [0.1, 0.2, 0.3] + assert embeddings[1] == [0.4, 0.5, 0.6] + assert usage['prompt_tokens'] == 20 + + +class TestInvokeRerank: + """Test invoke_rerank method""" + + @pytest.mark.asyncio + async def test_invoke_rerank_basic(self): + """Test basic rerank invocation""" + requester = litellmchat.LiteLLMRequester( + ap=Mock(), + config={ + 'base_url': 'https://api.cohere.ai', + 'custom_llm_provider': 'cohere', + }, + ) + + model = MockRuntimeRerankModel('rerank-english-v3.0', 'test-api-key') + + # Mock LiteLLM rerank response + mock_response = Mock() + mock_response.results = [ + {'index': 0, 'relevance_score': 0.95}, + {'index': 1, 'relevance_score': 0.3}, + {'index': 2, 'relevance_score': 0.8}, + ] + + with patch.object(litellmchat, 'arerank', new_callable=AsyncMock, return_value=mock_response): + results = await requester.invoke_rerank( + model=model, + query='What is the capital of France?', + documents=['Paris is the capital.', 'London is a city.', 'France is in Europe.'], + ) + + assert len(results) == 3 + # Scores should be normalized + assert results[0]['index'] == 0 + assert results[0]['relevance_score'] >= 0 and results[0]['relevance_score'] <= 1 + + @pytest.mark.asyncio + async def test_invoke_rerank_normalization(self): + """Test rerank score normalization""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={'custom_llm_provider': 'cohere'}) + + model = MockRuntimeRerankModel('rerank-english-v3.0', 'test-api-key') + + # Mock response with varying scores + mock_response = Mock() + mock_response.results = [ + {'index': 0, 'relevance_score': 0.9}, + {'index': 1, 'relevance_score': 0.1}, + ] + + with patch.object(litellmchat, 'arerank', new_callable=AsyncMock, return_value=mock_response): + results = await requester.invoke_rerank( + model=model, + query='test query', + documents=['doc1', 'doc2'], + ) + + # After normalization: 0.9 -> 1.0, 0.1 -> 0.0 + assert results[0]['relevance_score'] == 1.0 + assert results[1]['relevance_score'] == 0.0 + + @pytest.mark.asyncio + async def test_invoke_rerank_single_document(self): + """Test rerank with single document (no normalization needed)""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={'custom_llm_provider': 'cohere'}) + + model = MockRuntimeRerankModel('rerank-english-v3.0', 'test-api-key') + + mock_response = Mock() + mock_response.results = [ + {'index': 0, 'relevance_score': 0.5}, + ] + + with patch.object(litellmchat, 'arerank', new_callable=AsyncMock, return_value=mock_response): + results = await requester.invoke_rerank( + model=model, + query='test query', + documents=['doc1'], + ) + + assert len(results) == 1 + # Single score stays as is (min==max, no normalization) + assert results[0]['relevance_score'] == 0.5 + + @pytest.mark.asyncio + async def test_invoke_rerank_openai_compatible_http(self): + """OpenAI-compatible gateways (newapi/one-api/vLLM) must use the HTTP /v1/rerank + endpoint instead of litellm.arerank, which rejects the 'openai' provider.""" + requester = litellmchat.LiteLLMRequester( + ap=Mock(), + config={ + 'base_url': 'https://gateway.example.com/v1', + 'custom_llm_provider': 'openai', + }, + ) + + model = MockRuntimeRerankModel('bge-reranker-v2-m3', 'test-api-key') + + # Mock the standard Jina/Cohere-style /v1/rerank HTTP response + mock_resp = Mock() + mock_resp.raise_for_status = Mock() + mock_resp.json = Mock( + return_value={ + 'results': [ + {'index': 0, 'relevance_score': 0.9}, + {'index': 1, 'relevance_score': 0.1}, + ] + } + ) + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_resp) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + with patch('httpx.AsyncClient', return_value=mock_client): + # arerank must NOT be called on the openai-compatible path + with patch.object( + litellmchat, + 'arerank', + new_callable=AsyncMock, + side_effect=AssertionError('arerank must not be used for openai-compatible provider'), + ): + results = await requester.invoke_rerank( + model=model, + query='test query', + documents=['doc1', 'doc2'], + ) + + # Hit the standard rerank endpoint + called_url = mock_client.post.call_args[0][0] + assert called_url == 'https://gateway.example.com/v1/rerank' + # Payload carries the raw model name, query and documents + payload = mock_client.post.call_args[1]['json'] + assert payload['model'] == 'bge-reranker-v2-m3' + assert payload['query'] == 'test query' + assert payload['documents'] == ['doc1', 'doc2'] + # Scores normalized: 0.9 -> 1.0, 0.1 -> 0.0 + assert results[0]['relevance_score'] == 1.0 + assert results[1]['relevance_score'] == 0.0 + + @pytest.mark.asyncio + async def test_invoke_rerank_openai_compatible_score_alias(self): + """Some gateways return 'score' instead of 'relevance_score'; both must work.""" + requester = litellmchat.LiteLLMRequester( + ap=Mock(), + config={ + 'base_url': 'https://gateway.example.com/v1', + 'custom_llm_provider': 'openai', + }, + ) + + model = MockRuntimeRerankModel('bge-reranker-v2-m3', 'test-api-key') + + mock_resp = Mock() + mock_resp.raise_for_status = Mock() + mock_resp.json = Mock( + return_value={ + 'results': [ + {'index': 0, 'score': 0.8}, + {'index': 1, 'score': 0.2}, + ] + } + ) + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_resp) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + with patch('httpx.AsyncClient', return_value=mock_client): + results = await requester.invoke_rerank( + model=model, + query='test query', + documents=['doc1', 'doc2'], + ) + + assert results[0]['relevance_score'] == 1.0 + assert results[1]['relevance_score'] == 0.0 + + +class TestConvertMessages: + """Test _convert_messages method""" + + def test_convert_simple_message(self): + """Test converting simple text message""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + import langbot_plugin.api.entities.builtin.provider.message as provider_message + + messages = [provider_message.Message(role='user', content='Hello')] + result = requester._convert_messages(messages) + + assert len(result) == 1 + assert result[0]['role'] == 'user' + assert result[0]['content'] == 'Hello' + + def test_convert_message_with_image_base64(self): + """Test converting message with image_base64 content""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + import langbot_plugin.api.entities.builtin.provider.message as provider_message + + messages = [ + provider_message.Message( + role='user', + content=[ + {'type': 'text', 'text': 'What is in this image?'}, + {'type': 'image_base64', 'image_base64': 'data:image/png;base64,abc123'}, + ], + ) + ] + result = requester._convert_messages(messages) + + assert len(result) == 1 + content = result[0]['content'] + assert isinstance(content, list) + # Check image_base64 converted to image_url + image_part = [p for p in content if p.get('type') == 'image_url'][0] + assert 'image_url' in image_part + assert image_part['image_url']['url'] == 'data:image/png;base64,abc123' + + def test_convert_message_with_multiple_text_parts(self): + """Test converting message with multiple text parts (LiteLLM handles this)""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + import langbot_plugin.api.entities.builtin.provider.message as provider_message + + messages = [ + provider_message.Message( + role='user', + content=[ + {'type': 'text', 'text': 'Hello'}, + {'type': 'text', 'text': 'World'}, + ], + ) + ] + result = requester._convert_messages(messages) + + assert len(result) == 1 + # LiteLLM handles multiple text parts, we pass them through + assert isinstance(result[0]['content'], list) + + +class TestScanModels: + """Test scan_models method""" + + @pytest.mark.asyncio + async def test_scan_models_basic(self): + """Test basic model scanning""" + requester = litellmchat.LiteLLMRequester( + ap=Mock(), + config={ + 'base_url': 'https://api.openai.com/v1', + 'timeout': 60, + }, + ) + + # Mock httpx response + mock_response = Mock() + mock_response.json = Mock( + return_value={ + 'data': [ + {'id': 'gpt-4o'}, + {'id': 'text-embedding-3-small'}, + {'id': 'gpt-3.5-turbo'}, + ] + } + ) + mock_response.raise_for_status = Mock() + + with patch('httpx.AsyncClient') as mock_client: + mock_client.return_value.__aenter__ = AsyncMock(return_value=Mock()) + mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response) + + result = await requester.scan_models(api_key='test-key') + + assert 'models' in result + assert len(result['models']) == 3 + # Check LLM models are first + assert result['models'][0]['type'] == 'llm' + # Check embedding model is detected + embedding_models = [m for m in result['models'] if m['type'] == 'embedding'] + assert len(embedding_models) == 1 + + @pytest.mark.asyncio + async def test_scan_models_enriches_llm_abilities_and_context_length(self): + """Scanned LLM models get LiteLLM-derived abilities and context length.""" + requester = litellmchat.LiteLLMRequester( + ap=Mock(), + config={ + 'base_url': 'https://api.openai.com/v1', + 'timeout': 60, + }, + ) + requester._supports_function_calling = Mock(side_effect=lambda model_id: model_id == 'gpt-4o') + requester._supports_vision = Mock(side_effect=lambda model_id: model_id == 'gpt-4o') + requester._safe_context_length = Mock(side_effect=lambda model_id: 128000 if model_id == 'gpt-4o' else None) + + mock_response = Mock() + mock_response.json = Mock( + return_value={ + 'data': [ + {'id': 'gpt-4o'}, + {'id': 'text-embedding-3-small'}, + {'id': 'bge-reranker-v2'}, + ] + } + ) + mock_response.raise_for_status = Mock() + + with patch('httpx.AsyncClient') as mock_client: + mock_client.return_value.__aenter__ = AsyncMock(return_value=Mock()) + mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response) + + result = await requester.scan_models(api_key='test-key') + + by_id = {model['id']: model for model in result['models']} + assert by_id['gpt-4o']['abilities'] == ['func_call', 'vision'] + assert by_id['gpt-4o']['context_length'] == 128000 + assert by_id['text-embedding-3-small']['type'] == 'embedding' + assert by_id['bge-reranker-v2']['type'] == 'rerank' + + @pytest.mark.asyncio + async def test_scan_models_prefers_context_length_from_provider_payload(self): + """Provider-supplied context_length is preserved before LiteLLM metadata fallback.""" + requester = litellmchat.LiteLLMRequester( + ap=Mock(), + config={ + 'base_url': 'https://api.moonshot.cn/v1', + 'timeout': 60, + }, + ) + requester._supports_function_calling = Mock(return_value=False) + requester._supports_vision = Mock(return_value=False) + requester._safe_context_length = Mock(return_value=None) + + mock_response = Mock() + mock_response.json = Mock( + return_value={ + 'data': [ + {'id': 'moonshot-v1-128k', 'context_length': 131072}, + ] + } + ) + mock_response.raise_for_status = Mock() + + with patch('httpx.AsyncClient') as mock_client: + mock_client.return_value.__aenter__ = AsyncMock(return_value=Mock()) + mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response) + + result = await requester.scan_models(api_key='test-key') + + assert result['models'][0]['context_length'] == 131072 + requester._safe_context_length.assert_not_called() + + def test_safe_context_length_tries_moonshot_metadata_alias(self): + """OpenAI-compatible Moonshot endpoints still use Moonshot metadata for context windows.""" + requester = litellmchat.LiteLLMRequester( + ap=Mock(), + config={ + 'base_url': 'https://api.moonshot.cn/v1', + 'custom_llm_provider': 'openai', + }, + ) + + with patch.object(litellmchat.litellm, 'get_model_info') as mock_get_model_info: + mock_get_model_info.side_effect = ( + lambda model: {'max_input_tokens': 131072} if model == 'moonshot/moonshot-v1-128k' else {} + ) + + assert requester._safe_context_length('moonshot-v1-128k') == 131072 + + def test_safe_context_length_uses_litellm_max_input_tokens(self): + """LiteLLM max_output_tokens must not be treated as the context window.""" + requester = litellmchat.LiteLLMRequester(ap=Mock(), config={}) + + with patch.object(litellmchat.litellm, 'get_model_info') as mock_get_model_info: + mock_get_model_info.return_value = { + 'max_input_tokens': 128000, + 'max_output_tokens': 16384, + 'max_tokens': 16384, + } + + assert requester._safe_context_length('gpt-4o') == 128000 + + def test_litellm_bool_helper_tries_moonshot_metadata_alias(self): + """OpenAI-compatible Moonshot endpoints still use Moonshot metadata for abilities.""" + requester = litellmchat.LiteLLMRequester( + ap=Mock(), + config={ + 'base_url': 'https://api.moonshot.cn/v1', + 'custom_llm_provider': 'openai', + }, + ) + + with patch.object(litellmchat.litellm, 'supports_function_calling') as mock_supports_function_calling: + mock_supports_function_calling.side_effect = ( + lambda model, custom_llm_provider=None: model == 'moonshot/kimi-k2.6' and custom_llm_provider is None + ) + + assert requester._supports_function_calling('kimi-k2.6') is True + + @pytest.mark.asyncio + async def test_scan_models_uses_provider_payload_for_vision_ability(self): + """Provider-supplied vision support is used when scanning models.""" + requester = litellmchat.LiteLLMRequester( + ap=Mock(), + config={ + 'base_url': 'https://api.moonshot.cn/v1', + 'timeout': 60, + }, + ) + requester._supports_function_calling = Mock(return_value=False) + requester._supports_vision = Mock(return_value=False) + requester._safe_context_length = Mock(return_value=None) + + mock_response = Mock() + mock_response.json = Mock( + return_value={ + 'data': [ + { + 'id': 'moonshot-v1-128k-vision-preview', + 'supports_image_in': True, + }, + ] + } + ) + mock_response.raise_for_status = Mock() + + with patch('httpx.AsyncClient') as mock_client: + mock_client.return_value.__aenter__ = AsyncMock(return_value=Mock()) + mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response) + + result = await requester.scan_models(api_key='test-key') + + assert result['models'][0]['abilities'] == ['vision'] + + def test_safe_context_length_falls_back_for_deepseek_v4_models(self): + """DeepSeek V4 API ids have a known 1M context even before LiteLLM maps them.""" + requester = litellmchat.LiteLLMRequester( + ap=Mock(), + config={ + 'base_url': 'https://api.deepseek.com', + 'custom_llm_provider': 'deepseek', + }, + ) + + with patch.object(litellmchat.litellm, 'get_model_info', side_effect=Exception('not mapped')): + assert requester._safe_context_length('deepseek-v4-pro') == 1_000_000 + assert requester._safe_context_length('deepseek-v4-flash') == 1_000_000 + + @pytest.mark.asyncio + async def test_scan_models_no_base_url(self): + """Test scan_models without base_url raises error""" + requester = litellmchat.LiteLLMRequester( + ap=Mock(), + config={ + 'base_url': '', + }, + ) + + with pytest.raises(errors.RequesterError) as exc_info: + await requester.scan_models() + + assert 'Base URL required' in str(exc_info.value) + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/tests/unit_tests/provider/test_localagent_inbound_attachments.py b/tests/unit_tests/provider/test_localagent_inbound_attachments.py new file mode 100644 index 000000000..bc7352f13 --- /dev/null +++ b/tests/unit_tests/provider/test_localagent_inbound_attachments.py @@ -0,0 +1,146 @@ +"""Unit tests for LocalAgentRunner._inject_inbound_attachments. + +Covers the user -> sandbox attachment path added for the Box attachment +round-trip: + +* materialized descriptors are stashed on the query and described to the model + via an appended text note (in-sandbox paths + outbox convention); +* non-image file parts (file_base64 / file_url) are stripped from the user + message content because OpenAI-compatible chat models reject them, while + image and text parts are kept for vision models; +* the helper is a no-op when the box service is unavailable or yields nothing, + and never raises into the chat turn on materialization failure. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +import langbot_plugin.api.entities.builtin.provider.message as provider_message + +from langbot.pkg.provider.runners.localagent import LocalAgentRunner + + +def _make_runner(box_service) -> LocalAgentRunner: + runner = LocalAgentRunner.__new__(LocalAgentRunner) + runner.ap = SimpleNamespace(logger=Mock(), box_service=box_service) + return runner + + +def _make_query(): + return SimpleNamespace(variables={}, query_id='q-123') + + +def _box_service(attachments): + svc = SimpleNamespace( + available=True, + OUTBOX_MOUNT_DIR='/outbox', + materialize_inbound_attachments=AsyncMock(return_value=attachments), + ) + return svc + + +@pytest.mark.asyncio +async def test_inject_strips_file_parts_and_appends_note(): + box = _box_service([{'type': 'Voice', 'path': '/inbox/q-123/voice.wav', 'size': 176000}]) + runner = _make_runner(box) + query = _make_query() + user_message = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('transcribe this'), + provider_message.ContentElement.from_file_base64('data:audio/wav;base64,AAAA', 'voice.wav'), + ], + ) + + await runner._inject_inbound_attachments(query, user_message) + + types = [getattr(ce, 'type', None) for ce in user_message.content] + # file_base64 dropped; text kept; sandbox-path note appended as text + assert 'file_base64' not in types + assert types.count('text') == 2 + note = user_message.content[-1].text + assert '/inbox/q-123/voice.wav' in note + assert '/outbox/q-123' in note + # descriptors stashed for downstream stages + assert query.variables['_sandbox_inbound_attachments'] == box.materialize_inbound_attachments.return_value + + +@pytest.mark.asyncio +async def test_inject_keeps_image_parts(): + box = _box_service([{'type': 'Image', 'path': '/inbox/q-123/pic.png', 'size': 1234}]) + runner = _make_runner(box) + query = _make_query() + user_message = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('what is this'), + provider_message.ContentElement.from_image_base64('data:image/png;base64,iVBORw0K'), + ], + ) + + await runner._inject_inbound_attachments(query, user_message) + + types = [getattr(ce, 'type', None) for ce in user_message.content] + assert 'image_base64' in types # vision part preserved + assert types[-1] == 'text' # note appended last + + +@pytest.mark.asyncio +async def test_inject_promotes_string_content_to_list_with_note(): + box = _box_service([{'type': 'File', 'path': '/inbox/q-123/data.csv', 'size': 42}]) + runner = _make_runner(box) + query = _make_query() + user_message = provider_message.Message(role='user', content='clean this csv') + + await runner._inject_inbound_attachments(query, user_message) + + assert isinstance(user_message.content, list) + assert [getattr(ce, 'type', None) for ce in user_message.content] == ['text', 'text'] + assert user_message.content[0].text == 'clean this csv' + assert '/inbox/q-123/data.csv' in user_message.content[1].text + + +@pytest.mark.asyncio +async def test_inject_noop_without_box_service(): + runner = _make_runner(box_service=None) + query = _make_query() + user_message = provider_message.Message(role='user', content='hello') + + await runner._inject_inbound_attachments(query, user_message) + + assert user_message.content == 'hello' + assert '_sandbox_inbound_attachments' not in query.variables + + +@pytest.mark.asyncio +async def test_inject_noop_when_no_attachments(): + box = _box_service([]) + runner = _make_runner(box) + query = _make_query() + user_message = provider_message.Message(role='user', content='hello') + + await runner._inject_inbound_attachments(query, user_message) + + assert user_message.content == 'hello' + assert '_sandbox_inbound_attachments' not in query.variables + + +@pytest.mark.asyncio +async def test_inject_swallows_materialization_error(): + box = SimpleNamespace( + available=True, + OUTBOX_MOUNT_DIR='/outbox', + materialize_inbound_attachments=AsyncMock(side_effect=RuntimeError('disk full')), + ) + runner = _make_runner(box) + query = _make_query() + user_message = provider_message.Message(role='user', content='hello') + + # must not raise + await runner._inject_inbound_attachments(query, user_message) + assert user_message.content == 'hello' + runner.ap.logger.warning.assert_called_once() diff --git a/tests/unit_tests/provider/test_localagent_sandbox_exec.py b/tests/unit_tests/provider/test_localagent_sandbox_exec.py new file mode 100644 index 000000000..08b4c540b --- /dev/null +++ b/tests/unit_tests/provider/test_localagent_sandbox_exec.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.entities.builtin.provider.message as provider_message +import langbot_plugin.api.entities.builtin.provider.session as provider_session + +from langbot.pkg.provider.runners.localagent import LocalAgentRunner, _StreamAccumulator + + +class RecordingProvider: + def __init__(self): + self.requests: list[dict] = [] + + async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None): + self.requests.append( + { + 'messages': list(messages), + 'funcs': list(funcs), + 'remove_think': remove_think, + } + ) + + if len(self.requests) == 1: + return provider_message.Message( + role='assistant', + content='Let me calculate that exactly.', + tool_calls=[ + provider_message.ToolCall( + id='call-1', + type='function', + function=provider_message.FunctionCall( + name='exec', + arguments=json.dumps( + {'command': ("python - <<'PY'\nnums = [1, 2, 3, 4]\nprint(sum(nums) / len(nums))\nPY")} + ), + ), + ) + ], + ) + + tool_result = json.loads(messages[-1].content) + return provider_message.Message( + role='assistant', + content=f'The average is {tool_result["stdout"]}.', + ) + + +class RecordingStreamProvider: + def __init__(self): + self.stream_requests: list[dict] = [] + + def invoke_llm_stream(self, query, model, messages, funcs, extra_args=None, remove_think=None): + self.stream_requests.append( + { + 'messages': list(messages), + 'funcs': list(funcs), + 'remove_think': remove_think, + } + ) + + async def _stream(): + if len(self.stream_requests) == 1: + yield provider_message.MessageChunk( + role='assistant', + tool_calls=[ + provider_message.ToolCall( + id='call-1', + type='function', + function=provider_message.FunctionCall( + name='exec', + arguments=json.dumps({'command': "python -c 'print(1)'"}), + ), + ) + ], + is_final=True, + ) + return + + yield provider_message.MessageChunk( + role='assistant', + content='Tool execution failed.', + is_final=True, + ) + + return _stream() + + +def make_query() -> pipeline_query.Query: + adapter = AsyncMock() + adapter.is_stream_output_supported = AsyncMock(return_value=False) + + return pipeline_query.Query.model_construct( + query_id='avg-query', + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id=12345, + sender_id=12345, + message_chain=[], + message_event=None, + adapter=adapter, + pipeline_uuid='pipeline-uuid', + bot_uuid='bot-uuid', + pipeline_config={ + 'ai': { + 'runner': {'runner': 'local-agent'}, + 'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'}, + }, + 'output': {'misc': {'remove-think': False}}, + }, + prompt=SimpleNamespace(messages=[]), + messages=[], + user_message=provider_message.Message( + role='user', + content='Please calculate the average of 1, 2, 3, and 4.', + ), + use_funcs=[SimpleNamespace(name='exec')], + use_llm_model_uuid='test-model-uuid', + variables={}, + ) + + +def test_stream_accumulator_merges_fragmented_tool_call_arguments(): + accumulator = _StreamAccumulator(msg_sequence=1) + + assert ( + accumulator.add( + provider_message.MessageChunk( + role='assistant', + tool_calls=[ + provider_message.ToolCall( + id='call-1', + type='function', + function=provider_message.FunctionCall(name='exec', arguments='{"command":'), + ) + ], + ) + ) + is None + ) + + emitted = accumulator.add( + provider_message.MessageChunk( + role='assistant', + tool_calls=[ + provider_message.ToolCall( + id='call-1', + type='function', + function=provider_message.FunctionCall(name='exec', arguments='"pwd"}'), + ) + ], + is_final=True, + ) + ) + + assert emitted is not None + final_msg = accumulator.final_message() + assert final_msg.tool_calls[0].function.name == 'exec' + assert final_msg.tool_calls[0].function.arguments == '{"command":"pwd"}' + + +@pytest.mark.asyncio +async def test_localagent_uses_exec_for_exact_calculation(): + provider = RecordingProvider() + model = SimpleNamespace( + provider=provider, + model_entity=SimpleNamespace( + uuid='test-model-uuid', + name='test-model', + abilities=['func_call'], + extra_args={}, + ), + ) + + tool_manager = SimpleNamespace( + execute_func_call=AsyncMock( + return_value={ + 'session_id': 'avg-query', + 'backend': 'podman', + 'status': 'completed', + 'ok': True, + 'exit_code': 0, + 'stdout': '2.5', + 'stderr': '', + 'duration_ms': 18, + } + ) + ) + + app = SimpleNamespace( + logger=Mock(), + model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)), + tool_mgr=tool_manager, + rag_mgr=SimpleNamespace(), + box_service=SimpleNamespace( + get_system_guidance=Mock( + return_value=( + 'When the exec tool is available, use it for exact calculations, statistics, ' + 'structured data parsing, and code execution instead of estimating mentally. ' + 'Unless the user explicitly asks for the script, code, or implementation details, ' + 'do not include the generated script in the final answer. ' + 'A default workspace is mounted at /workspace for file tasks.' + ) + ), + ), + skill_mgr=SimpleNamespace( + get_skills_for_pipeline=AsyncMock(return_value=[]), + detect_skill_activation=AsyncMock(return_value=None), + build_activation_prompt=Mock(return_value=None), + ), + ) + + runner = LocalAgentRunner(app, pipeline_config={}) + query = make_query() + + results = [message async for message in runner.run(query)] + + assert [message.role for message in results] == ['assistant', 'tool', 'assistant'] + assert results[-1].content == 'The average is 2.5.' + + tool_manager.execute_func_call.assert_awaited_once() + tool_name, tool_parameters = tool_manager.execute_func_call.await_args.args[:2] + assert tool_name == 'exec' + assert 'print(sum(nums) / len(nums))' in tool_parameters['command'] + + first_request = provider.requests[0] + assert any( + message.role == 'system' + and 'exec' in str(message.content) + and 'exact calculations' in str(message.content) + and 'Unless the user explicitly asks for the script' in str(message.content) + and '/workspace' in str(message.content) + for message in first_request['messages'] + ) + assert [tool.name for tool in first_request['funcs']] == ['exec'] + + +@pytest.mark.asyncio +async def test_localagent_streaming_tool_error_yields_message_chunks(): + provider = RecordingStreamProvider() + model = SimpleNamespace( + provider=provider, + model_entity=SimpleNamespace( + uuid='test-model-uuid', + name='test-model', + abilities=['func_call'], + extra_args={}, + ), + ) + + adapter = AsyncMock() + adapter.is_stream_output_supported = AsyncMock(return_value=True) + + query = make_query() + query.adapter = adapter + + app = SimpleNamespace( + logger=Mock(), + model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)), + tool_mgr=SimpleNamespace(execute_func_call=AsyncMock(side_effect=RuntimeError('boom'))), + rag_mgr=SimpleNamespace(), + box_service=SimpleNamespace( + get_system_guidance=Mock(return_value='sandbox guidance'), + ), + skill_mgr=SimpleNamespace( + get_skills_for_pipeline=AsyncMock(return_value=[]), + detect_skill_activation=AsyncMock(return_value=None), + build_activation_prompt=Mock(return_value=None), + ), + ) + + runner = LocalAgentRunner(app, pipeline_config={}) + + results = [message async for message in runner.run(query)] + + assert all(isinstance(message, provider_message.MessageChunk) for message in results) + assert any(message.role == 'tool' and message.content == 'err: boom' for message in results) diff --git a/tests/unit_tests/provider/test_mcp_box_integration.py b/tests/unit_tests/provider/test_mcp_box_integration.py new file mode 100644 index 000000000..6b0c141ed --- /dev/null +++ b/tests/unit_tests/provider/test_mcp_box_integration.py @@ -0,0 +1,957 @@ +"""Tests for MCP Box integration: path rewriting, host_path inference, config model, payloads. + +Uses importlib.util.spec_from_file_location to load mcp.py directly without +triggering the circular import chain through the app module. +""" + +from __future__ import annotations + +import importlib +import importlib.util +import os +import sys +import tempfile +import types +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + + +# --------------------------------------------------------------------------- +# Load mcp.py directly from file path, with stub dependencies +# --------------------------------------------------------------------------- + + +def _stub_module(fqn: str, attrs: dict | None = None, is_package: bool = False): + """Create or return a stub module and register it in sys.modules.""" + if fqn in sys.modules: + mod = sys.modules[fqn] + else: + mod = types.ModuleType(fqn) + mod.__spec__ = importlib.machinery.ModuleSpec(fqn, None, is_package=is_package) + if is_package: + mod.__path__ = [] + sys.modules[fqn] = mod + parts = fqn.rsplit('.', 1) + if len(parts) == 2 and parts[0] in sys.modules: + setattr(sys.modules[parts[0]], parts[1], mod) + if attrs: + for k, v in attrs.items(): + setattr(mod, k, v) + return mod + + +@pytest.fixture(scope='module', autouse=True) +def mcp_module(): + """Load mcp.py with minimal stubs to avoid circular imports.""" + saved = {} + + def _save_and_stub(name, attrs=None, is_package=False): + saved[name] = sys.modules.get(name) + # Don't overwrite modules that already exist (from other test modules) + if name in sys.modules: + return + _stub_module(name, attrs, is_package) + + # Stub entire dependency chains as packages / modules + _save_and_stub('langbot_plugin', is_package=True) + _save_and_stub('langbot_plugin.api', is_package=True) + _save_and_stub('langbot_plugin.api.entities', is_package=True) + _save_and_stub('langbot_plugin.api.entities.events', is_package=True) + _save_and_stub('langbot_plugin.api.entities.events.pipeline_query', {}) + _save_and_stub('langbot_plugin.api.entities.builtin', is_package=True) + _save_and_stub('langbot_plugin.api.entities.builtin.resource', is_package=True) + _save_and_stub( + 'langbot_plugin.api.entities.builtin.resource.tool', + { + 'LLMTool': type('LLMTool', (), {}), + }, + ) + _save_and_stub('langbot_plugin.api.entities.builtin.provider', is_package=True) + _save_and_stub('langbot_plugin.api.entities.builtin.provider.message', {}) + _save_and_stub('sqlalchemy', {'select': Mock()}) + _save_and_stub('httpx', {'AsyncClient': Mock()}) + _save_and_stub('mcp', {'ClientSession': Mock, 'StdioServerParameters': Mock}, is_package=True) + _save_and_stub('mcp.client', is_package=True) + _save_and_stub('mcp.client.stdio', {'stdio_client': Mock()}) + _save_and_stub('mcp.client.sse', {'sse_client': Mock()}) + _save_and_stub('mcp.client.streamable_http', {'streamable_http_client': Mock()}) + _save_and_stub('mcp.client.websocket', {'websocket_client': Mock()}) + + # Stub the provider.tools.loader (source of circular import) + _save_and_stub('langbot', is_package=True) + _save_and_stub('langbot.pkg', is_package=True) + _save_and_stub('langbot.pkg.provider', is_package=True) + _save_and_stub('langbot.pkg.provider.tools', is_package=True) + _save_and_stub( + 'langbot.pkg.provider.tools.loader', + { + 'ToolLoader': type('ToolLoader', (), {'__init__': lambda self, ap: None}), + }, + ) + _save_and_stub('langbot.pkg.provider.tools.loaders', is_package=True) + _save_and_stub('langbot.pkg.core', is_package=True) + _save_and_stub('langbot.pkg.core.app', {'Application': type('Application', (), {})}) + _save_and_stub('langbot.pkg.entity', is_package=True) + _save_and_stub('langbot.pkg.entity.persistence', is_package=True) + _save_and_stub('langbot.pkg.entity.persistence.mcp', {}) + + # box models + import enum as _enum + + class _BPS(str, _enum.Enum): + RUNNING = 'running' + EXITED = 'exited' + + _save_and_stub('langbot_plugin.box', is_package=True) + _save_and_stub('langbot_plugin.box.models', {'BoxManagedProcessStatus': _BPS}) + + # Now load mcp.py via spec_from_file_location + mod_fqn = 'langbot.pkg.provider.tools.loaders.mcp' + sys.modules.pop(mod_fqn, None) + mcp_path = os.path.join( + os.path.dirname(__file__), + '..', + '..', + '..', + 'src', + 'langbot', + 'pkg', + 'provider', + 'tools', + 'loaders', + 'mcp.py', + ) + mcp_path = os.path.normpath(mcp_path) + pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(mcp_path)))) + sys.modules['langbot.pkg'].__path__ = [pkg_root] + sys.modules['langbot.pkg.provider.tools.loaders'].__path__ = [os.path.dirname(mcp_path)] + spec = importlib.util.spec_from_file_location(mod_fqn, mcp_path) + mod = importlib.util.module_from_spec(spec) + sys.modules[mod_fqn] = mod + spec.loader.exec_module(mod) + + yield mod + + # Cleanup + sys.modules.pop(mod_fqn, None) + sys.modules.pop('langbot.pkg.provider.tools.loaders.mcp_stdio', None) + sys.modules.pop('langbot.pkg.box.workspace', None) + for name in reversed(list(saved)): + if saved[name] is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = saved[name] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_ap(): + ap = Mock() + ap.logger = Mock() + ap.box_service = Mock() + return ap + + +def _make_session(mcp_module, server_config: dict, ap=None): + if ap is None: + ap = _make_ap() + return mcp_module.RuntimeMCPSession( + server_name=server_config.get('name', 'test-server'), + server_config=server_config, + enable=True, + ap=ap, + ) + + +# ── MCPServerBoxConfig ────────────────────────────────────────────── + + +class TestMCPServerBoxConfig: + def test_default_values(self, mcp_module): + cfg = mcp_module.MCPServerBoxConfig.model_validate({}) + assert cfg.image is None + assert cfg.network == 'on' + assert cfg.host_path is None + assert cfg.host_path_mode == 'ro' + assert cfg.env == {} + assert cfg.startup_timeout_sec == 300 + assert cfg.cpus is None + assert cfg.memory_mb is None + assert cfg.pids_limit is None + assert cfg.read_only_rootfs is None + + def test_custom_values(self, mcp_module): + cfg = mcp_module.MCPServerBoxConfig.model_validate( + { + 'image': 'node:20', + 'network': 'on', + 'host_path': '/home/user/mcp', + 'host_path_mode': 'rw', + 'env': {'FOO': 'bar'}, + 'startup_timeout_sec': 60, + 'cpus': 2.0, + 'memory_mb': 1024, + 'pids_limit': 256, + 'read_only_rootfs': False, + } + ) + assert cfg.image == 'node:20' + assert cfg.network == 'on' + assert cfg.cpus == 2.0 + assert cfg.memory_mb == 1024 + + def test_extra_fields_ignored(self, mcp_module): + cfg = mcp_module.MCPServerBoxConfig.model_validate( + { + 'image': 'node:20', + 'unknown_field': 'whatever', + } + ) + assert cfg.image == 'node:20' + assert not hasattr(cfg, 'unknown_field') + + +# ── Path Rewriting ────────────────────────────────────────────────── + + +class TestRewritePath: + def test_no_host_path_returns_unchanged(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': [], + }, + ) + assert s._rewrite_path('/some/path', None) == '/some/path' + + def test_empty_path_returns_empty(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': [], + }, + ) + assert s._rewrite_path('', '/home/user/mcp') == '' + + def test_prefix_match_rewrites(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': [], + }, + ) + result = s._rewrite_path('/home/user/mcp/server.py', '/home/user/mcp') + assert result == '/workspace/server.py' + + def test_exact_match_rewrites_to_workspace(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': [], + }, + ) + result = s._rewrite_path('/home/user/mcp', '/home/user/mcp') + assert result == '/workspace' + + def test_non_matching_path_unchanged(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': [], + }, + ) + result = s._rewrite_path('/opt/other/server.py', '/home/user/mcp') + assert result == '/opt/other/server.py' + + def test_similar_prefix_not_rewritten(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': [], + }, + ) + result = s._rewrite_path('/home/user/mcp-other/file.py', '/home/user/mcp') + assert result == '/home/user/mcp-other/file.py' + + def test_nested_subpath_rewrites(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': [], + }, + ) + result = s._rewrite_path('/home/user/mcp/src/lib/main.py', '/home/user/mcp') + assert result == '/workspace/src/lib/main.py' + + +# ── host_path Inference ───────────────────────────────────────────── + + +class TestInferHostPath: + def test_no_absolute_paths_returns_none(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': ['server.py'], + }, + ) + assert s._infer_host_path() is None + + def test_nonexistent_path_returns_none(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': '/nonexistent/path/to/python', + 'args': [], + }, + ) + assert s._infer_host_path() is None + + def test_existing_absolute_path_infers_directory(self, mcp_module): + with tempfile.NamedTemporaryFile(suffix='.py') as f: + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': [f.name], + }, + ) + result = s._infer_host_path() + assert result is not None + assert result == os.path.dirname(os.path.realpath(f.name)) + + +# ── Build Box Session Payload ─────────────────────────────────────── + + +class TestBuildBoxSessionPayload: + def test_minimal_config(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': [], + }, + ) + payload = s._build_box_session_payload('session-123') + assert payload['session_id'] == 'session-123' + assert payload['workdir'] == '/workspace' + assert payload['env'] == {} + assert 'host_path' not in payload + + def test_with_host_path(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': [], + 'box': {'host_path': '/home/user/mcp', 'host_path_mode': 'ro'}, + }, + ) + payload = s._build_box_session_payload('session-123') + assert payload['host_path'] == '/home/user/mcp' + assert payload['host_path_mode'] == 'ro' + + def test_optional_fields_included_when_set(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': [], + 'box': {'image': 'node:20', 'cpus': 2.0, 'memory_mb': 1024, 'pids_limit': 256}, + }, + ) + payload = s._build_box_session_payload('session-123') + assert payload['image'] == 'node:20' + assert payload['cpus'] == 2.0 + assert payload["memory_mb"] == 1024 + assert payload['pids_limit'] == 256 + + def test_none_fields_excluded(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': [], + }, + ) + payload = s._build_box_session_payload('session-123') + assert 'image' not in payload + assert 'cpus' not in payload + + +# ── Build Box Process Payload ─────────────────────────────────────── + + +class TestBuildBoxProcessPayload: + def test_basic_payload(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': ['server.py'], + 'env': {'KEY': 'val'}, + }, + ) + payload = s._build_box_process_payload() + assert payload['command'] == 'python' + assert payload['args'] == ['server.py'] + assert payload['env'] == {'KEY': 'val'} + assert payload['cwd'] == '/workspace' + + def test_path_rewriting_applied(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': '/home/user/mcp/venv/bin/python', + 'args': ['/home/user/mcp/server.py', '--config', '/home/user/mcp/config.json'], + 'env': {}, + 'box': {'host_path': '/home/user/mcp'}, + }, + ) + payload = s._build_box_process_payload() + # venv python is replaced with plain 'python' (deps installed in-container) + assert payload['command'] == 'python' + assert payload['args'] == ['/workspace/server.py', '--config', '/workspace/config.json'] + + def test_non_matching_args_not_rewritten(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': ['/opt/other/server.py', '--flag'], + 'env': {}, + 'box': {'host_path': '/home/user/mcp'}, + }, + ) + payload = s._build_box_process_payload() + assert payload['command'] == 'python' + assert payload['args'] == ['/opt/other/server.py', '--flag'] + + +# ── Python Workspace Preparation ──────────────────────────────────── + + +class TestPythonWorkspacePreparation: + def test_requirements_workspace_uses_venv_bootstrap(self, mcp_module, tmp_path): + host_path = tmp_path / 'mcp-source' + host_path.mkdir() + (host_path / 'requirements.txt').write_text('mcp==1.26.0\n', encoding='utf-8') + + command = mcp_module.BoxStdioSessionRuntime.detect_install_command( + str(host_path), + '/workspace/.mcp/u1/workspace', + ) + + assert command is not None + assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command + assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command + assert 'python -m pip install -r "/workspace/.mcp/u1/workspace/requirements.txt"' in command + assert 'pip install --no-cache-dir -r' not in command + + def test_staging_refresh_removes_stale_source_files_but_preserves_runtime_dirs(self, mcp_module, tmp_path): + source = tmp_path / 'source' + source.mkdir() + (source / 'server.py').write_text('print("new")\n', encoding='utf-8') + (source / 'requirements.txt').write_text('mcp==1.26.0\n', encoding='utf-8') + (source / '.env').write_text('TOKEN=new\n', encoding='utf-8') + + process_root = tmp_path / 'shared' / '.mcp' / 'u1' + workspace = process_root / 'workspace' + (workspace / '.venv' / 'bin').mkdir(parents=True) + (workspace / '.venv' / 'bin' / 'python').write_text('', encoding='utf-8') + (workspace / '.langbot').mkdir() + (workspace / '.langbot' / 'python-env.lock').mkdir() + (workspace / '.env').write_text('TOKEN=old\n', encoding='utf-8') + (workspace / 'server.py').write_text('print("old")\n', encoding='utf-8') + (workspace / 'removed.py').write_text('stale\n', encoding='utf-8') + (workspace / 'removed_dir').mkdir() + (workspace / 'removed_dir' / 'old.txt').write_text('stale\n', encoding='utf-8') + + mcp_module.BoxStdioSessionRuntime._copy_workspace_tree(str(source), str(process_root), str(workspace)) + + assert (workspace / 'server.py').read_text(encoding='utf-8') == 'print("new")\n' + assert (workspace / 'requirements.txt').read_text(encoding='utf-8') == 'mcp==1.26.0\n' + assert (workspace / '.env').read_text(encoding='utf-8') == 'TOKEN=new\n' + assert not (workspace / 'removed.py').exists() + assert not (workspace / 'removed_dir').exists() + assert (workspace / '.venv' / 'bin' / 'python').exists() + assert (workspace / '.langbot' / 'python-env.lock').is_dir() + + def test_staging_refresh_ignores_unlink_race(self, mcp_module, tmp_path, monkeypatch): + mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio'] + + source = tmp_path / 'source' + source.mkdir() + (source / 'server.py').write_text('print("new")\n', encoding='utf-8') + + process_root = tmp_path / 'shared' / '.mcp' / 'u1' + workspace = process_root / 'workspace' + workspace.mkdir(parents=True) + stale_file = workspace / 'removed.py' + stale_file.write_text('stale\n', encoding='utf-8') + + real_unlink = os.unlink + + def unlink_with_race(path): + if os.fspath(path) == str(stale_file): + real_unlink(path) + raise FileNotFoundError(path) + real_unlink(path) + + monkeypatch.setattr(mcp_stdio_module.os, 'unlink', unlink_with_race) + + mcp_module.BoxStdioSessionRuntime._copy_workspace_tree(str(source), str(process_root), str(workspace)) + + assert not stale_file.exists() + assert (workspace / 'server.py').read_text(encoding='utf-8') == 'print("new")\n' + + +# ── get_runtime_info_dict ─────────────────────────────────────────── + + +class TestGetRuntimeInfoDict: + def test_non_stdio_session(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'test-uuid', + 'mode': 'sse', + 'command': 'python', + 'args': [], + }, + ) + info = s.get_runtime_info_dict() + assert info['status'] == 'connecting' + assert 'box_session_id' not in info + + def test_runtime_tools_include_parameters(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'test-uuid', + 'mode': 'sse', + 'command': 'python', + 'args': [], + }, + ) + s.functions = [ + SimpleNamespace( + name='create-service', + description='Create a service', + parameters={ + 'type': 'object', + 'properties': { + 'project_id': {'type': 'string'}, + }, + 'required': ['project_id'], + }, + ) + ] + + info = s.get_runtime_info_dict() + + assert info['tools'][0]['parameters']['properties']['project_id']['type'] == 'string' + assert info['tools'][0]['parameters']['required'] == ['project_id'] + + def test_stdio_session_includes_box_info(self, mcp_module): + ap = _make_ap() + ap.box_service.available = True + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'test-uuid', + 'mode': 'stdio', + 'command': 'python', + 'args': [], + }, + ap=ap, + ) + info = s.get_runtime_info_dict() + assert info['box_session_id'] == 'mcp-shared' + assert info['box_enabled'] is True + + def test_transient_test_shares_session_but_isolated_by_process(self, mcp_module): + """A transient config-page "test" now shares the same 'mcp-shared' Box + session as live servers (so a test reuses the running container / live + process instead of a cold per-test session bootstrap). Isolation is at + the PROCESS level: the test runs under its own process_id and only ever + stops that process_id, so it cannot disturb another server's live + process or the shared session itself.""" + ap = _make_ap() + ap.box_service.available = True + transient = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'gen-uuid-123', + 'mode': 'stdio', + 'command': 'uvx', + 'args': ['mcp-server-time'], + '_transient': True, + }, + ap=ap, + ) + live = _make_session( + mcp_module, + { + 'name': 'time', + 'uuid': 'real-uuid', + 'mode': 'stdio', + 'command': 'uvx', + 'args': ['mcp-server-time'], + }, + ap=ap, + ) + assert transient.is_transient is True + assert live.is_transient is False + # Both share ONE Box session ... + assert transient._build_box_session_id() == 'mcp-shared' + assert live._build_box_session_id() == 'mcp-shared' + assert transient._build_box_session_id() == live._build_box_session_id() + # ... but are isolated by distinct process_ids within that session. + assert transient._box_stdio_runtime.process_id != live._box_stdio_runtime.process_id + + def test_stdio_session_refuses_when_box_unavailable(self, mcp_module): + """Policy: when Box is configured but unavailable (disabled in config + OR connection failed), stdio MCP servers are NOT treated as box-stdio. + ``_init_stdio_python_server`` will raise a clear refusal at start + time; until then, the runtime info simply omits box_session_id so the + UI can render the disabled state cleanly.""" + ap = _make_ap() + ap.box_service.available = False + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'test-uuid', + 'mode': 'stdio', + 'command': 'python', + 'args': [], + }, + ap=ap, + ) + info = s.get_runtime_info_dict() + assert 'box_session_id' not in info + assert 'box_enabled' not in info + + def test_stdio_session_without_box_service_uses_local_stdio(self, mcp_module): + ap = _make_ap() + del ap.box_service + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'test-uuid', + 'mode': 'stdio', + 'command': 'python', + 'args': [], + }, + ap=ap, + ) + info = s.get_runtime_info_dict() + assert 'box_session_id' not in info + + +# ── Box config parsing ────────────────────────────────────────────── + + +class TestBoxConfigParsing: + def test_box_config_parsed_from_server_config(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': [], + 'box': {'image': 'node:20', 'host_path': '/home/user/mcp'}, + }, + ) + assert isinstance(s.box_config, mcp_module.MCPServerBoxConfig) + assert s.box_config.image == 'node:20' + assert s.box_config.host_path == '/home/user/mcp' + + def test_missing_box_key_uses_defaults(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'sse', + 'command': 'python', + 'args': [], + }, + ) + assert isinstance(s.box_config, mcp_module.MCPServerBoxConfig) + assert s.box_config.image is None + assert s.box_config.host_path_mode == 'ro' + + +@pytest.mark.asyncio +async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_module, tmp_path): + mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio'] + + class FakeClientSession: + def __init__(self, *_args): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def initialize(self): + return None + + @asynccontextmanager + async def fake_websocket_client(_url: str): + yield ('read-stream', 'write-stream') + + mcp_stdio_module.ClientSession = FakeClientSession + mcp_stdio_module.websocket_client = fake_websocket_client + + ap = _make_ap() + ap.box_service.available = True + ap.box_service.default_workspace = str(tmp_path / 'shared-box-workspace') + ap.box_service.create_session = AsyncMock(return_value={}) + ap.box_service.build_spec = Mock(return_value='validated-spec') + ap.box_service.client = SimpleNamespace( + execute=AsyncMock(return_value=SimpleNamespace(ok=True, stderr='', exit_code=0)) + ) + ap.box_service.start_managed_process = AsyncMock(return_value={}) + ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box.example/process') + + host_path = tmp_path / 'mcp-source' + host_path.mkdir() + server_file = host_path / 'server.py' + server_file.write_text('print("hello")\n', encoding='utf-8') + + session = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'u1', + 'mode': 'stdio', + 'command': str(host_path / '.venv' / 'bin' / 'python'), + 'args': [str(server_file)], + 'box': {'host_path': str(host_path)}, + }, + ap=ap, + ) + + await session._init_box_stdio_server() + await session.exit_stack.aclose() + + assert ap.box_service.create_session.await_count == 1 + session_payload = ap.box_service.create_session.await_args.args[0] + assert session_payload['session_id'] == 'mcp-shared' + assert 'host_path' not in session_payload + assert ap.box_service.build_spec.call_count == 1 + assert ap.box_service.build_spec.call_args.kwargs.get('skip_host_mount_validation', False) is False + assert ap.box_service.build_spec.call_args.args[0]['host_path'] == str(host_path) + + staged_file = tmp_path / 'shared-box-workspace' / '.mcp' / 'u1' / 'workspace' / 'server.py' + assert staged_file.read_text(encoding='utf-8') == 'print("hello")\n' + + process_payload = ap.box_service.start_managed_process.await_args.args[1] + assert process_payload['process_id'] == 'u1' + assert process_payload['command'] == 'python' + assert process_payload['args'] == ['/workspace/.mcp/u1/workspace/server.py'] + assert process_payload['cwd'] == '/workspace/.mcp/u1/workspace' + + +@pytest.mark.asyncio +async def test_stdio_handshake_raises_coldstart_retry_while_process_alive(mcp_module, tmp_path, monkeypatch): + """During a slow (npx) cold start the handshake fails while the managed + process is still alive. initialize() must raise _ColdStartRetry (so the + outer lifecycle loop reuses the live process and retries without stopping it + or consuming the fatal budget), NOT a fatal error.""" + from contextlib import asynccontextmanager + + mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio'] + + class ColdClientSession: + def __init__(self, *_args): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def initialize(self): + # Process still cold-starting: handshake fails. + raise Exception('Connection closed') + + @asynccontextmanager + async def fake_websocket_client(_url: str): + yield ('read-stream', 'write-stream') + + monkeypatch.setattr(mcp_stdio_module, 'ClientSession', ColdClientSession) + monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client) + monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False) + + ap = _make_ap() + ap.box_service.available = True + ap.box_service.create_session = AsyncMock(return_value={}) + ap.box_service.start_managed_process = AsyncMock(return_value={}) + ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p') + + session = _make_session( + mcp_module, + { + 'name': 'slow', + 'uuid': 'slow-uuid', + 'mode': 'stdio', + 'command': 'npx', + 'args': ['-y', 'some-mcp'], + }, + ap=ap, + ) + + # Process is NOT exited (still cold-starting) and not yet running for reuse. + async def _not_exited(): + return False + + session._box_stdio_runtime._managed_process_has_exited = _not_exited + + async def _not_running(): + return False + + session._box_stdio_runtime._managed_process_is_running = _not_running + + with pytest.raises(mcp_stdio_module._ColdStartRetry): + await session._init_box_stdio_server() + + # Process was started exactly once (the retry will reuse it, not rebuild). + assert ap.box_service.start_managed_process.await_count == 1 + await session.exit_stack.aclose() + + +@pytest.mark.asyncio +async def test_stdio_handshake_raises_fatal_when_process_exited(mcp_module, tmp_path, monkeypatch): + """If the handshake fails AND the process has definitively exited, that is a + real failure — initialize() must NOT swallow it as a cold-start retry.""" + from contextlib import asynccontextmanager + + mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio'] + + class DeadClientSession: + def __init__(self, *_args): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def initialize(self): + raise Exception('Connection closed') + + @asynccontextmanager + async def fake_websocket_client(_url: str): + yield ('read-stream', 'write-stream') + + monkeypatch.setattr(mcp_stdio_module, 'ClientSession', DeadClientSession) + monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client) + monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False) + + ap = _make_ap() + ap.box_service.available = True + ap.box_service.create_session = AsyncMock(return_value={}) + ap.box_service.start_managed_process = AsyncMock(return_value={}) + ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p') + + session = _make_session( + mcp_module, + {'name': 'dead', 'uuid': 'dead-uuid', 'mode': 'stdio', 'command': 'npx', 'args': ['-y', 'x']}, + ap=ap, + ) + + async def _exited(): + return True + + session._box_stdio_runtime._managed_process_has_exited = _exited + + async def _not_running(): + return False + + session._box_stdio_runtime._managed_process_is_running = _not_running + + with pytest.raises(Exception) as ei: + await session._init_box_stdio_server() + assert not isinstance(ei.value, mcp_stdio_module._ColdStartRetry) + await session.exit_stack.aclose() diff --git a/tests/unit_tests/provider/test_mcp_remote_transport.py b/tests/unit_tests/provider/test_mcp_remote_transport.py new file mode 100644 index 000000000..b2f1d2e14 --- /dev/null +++ b/tests/unit_tests/provider/test_mcp_remote_transport.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import asyncio +import json +from contextlib import asynccontextmanager +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest +from aiohttp import web +from mcp import types as mcp_types + +from langbot.pkg.provider.tools.loaders.mcp import RuntimeMCPSession + + +class _TransportProbe: + def __init__(self, streamable_status: int | None) -> None: + self.streamable_status = streamable_status + self.streamable_posts = 0 + self.streamable_messages: list[str] = [] + self.sse_gets = 0 + self.sse_messages: list[str] = [] + self.streamable_request_started = asyncio.Event() + self.release_streamable_request = asyncio.Event() + self._sse_response: web.StreamResponse | None = None + + async def handle_mcp_endpoint(self, request: web.Request) -> web.StreamResponse: + if request.method == 'POST': + self.streamable_posts += 1 + self.streamable_request_started.set() + if self.streamable_status is None: + await self.release_streamable_request.wait() + return web.Response(status=204) + if self.streamable_status == 200: + message = await request.json() + method = message.get('method', '') + self.streamable_messages.append(method) + if method == 'initialize': + return web.json_response( + { + 'jsonrpc': '2.0', + 'id': message['id'], + 'result': { + 'protocolVersion': mcp_types.LATEST_PROTOCOL_VERSION, + 'capabilities': {'tools': {}}, + 'serverInfo': {'name': 'streamable-test', 'version': '1.0.0'}, + }, + } + ) + if method == 'tools/list': + return web.json_response( + { + 'jsonrpc': '2.0', + 'id': message['id'], + 'result': { + 'tools': [ + { + 'name': 'echo', + 'description': 'Echo test input', + 'inputSchema': {'type': 'object'}, + } + ] + }, + } + ) + return web.Response(status=202) + return web.Response(status=self.streamable_status) + + self.sse_gets += 1 + response = web.StreamResponse( + status=200, + headers={ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + }, + ) + await response.prepare(request) + self._sse_response = response + await response.write(b'event: endpoint\ndata: /messages?session_id=test-session\n\n') + try: + while request.transport is not None and not request.transport.is_closing(): + await asyncio.sleep(0.05) + except asyncio.CancelledError: + raise + return response + + async def handle_sse_message(self, request: web.Request) -> web.Response: + message = await request.json() + method = message.get('method', '') + self.sse_messages.append(method) + + if method == 'initialize': + response_message = { + 'jsonrpc': '2.0', + 'id': message['id'], + 'result': { + 'protocolVersion': mcp_types.LATEST_PROTOCOL_VERSION, + 'capabilities': {}, + 'serverInfo': {'name': 'legacy-sse-test', 'version': '1.0.0'}, + }, + } + assert self._sse_response is not None + payload = json.dumps(response_message, separators=(',', ':')) + await self._sse_response.write(f'event: message\ndata: {payload}\n\n'.encode()) + + return web.Response(status=202) + + +@asynccontextmanager +async def _transport_server(streamable_status: int | None): + probe = _TransportProbe(streamable_status) + application = web.Application() + application.router.add_route('*', '/mcp', probe.handle_mcp_endpoint) + application.router.add_post('/messages', probe.handle_sse_message) + runner = web.AppRunner(application, shutdown_timeout=0.1) + await runner.setup() + site = web.TCPSite(runner, '127.0.0.1', 0) + await site.start() + server = cast(asyncio.Server, site._server) + port = server.sockets[0].getsockname()[1] + try: + yield probe, f'http://127.0.0.1:{port}/mcp' + finally: + await runner.cleanup() + + +def _session(url: str, *, timeout: float = 2) -> RuntimeMCPSession: + app = cast(Any, SimpleNamespace(logger=Mock())) + return RuntimeMCPSession( + 'remote-transport-test', + {'uuid': 'srv-1', 'mode': 'remote', 'url': url, 'timeout': timeout}, + True, + app, + ) + + +def _contains_http_status(exc: BaseException, status_code: int) -> bool: + return any( + isinstance(leaf, httpx.HTTPStatusError) and leaf.response.status_code == status_code + for leaf in RuntimeMCPSession._iter_exception_leaves(exc) + ) + + +async def _close_session(session: RuntimeMCPSession) -> None: + await session.exit_stack.aclose() + + +@pytest.mark.asyncio +async def test_remote_transport_real_streamable_http_success_keeps_session_usable(): + async with _transport_server(200) as (probe, url): + session = _session(url) + try: + await session._init_remote_server() + assert session.session is not None + tools = await session.session.list_tools() + assert [tool.name for tool in tools.tools] == ['echo'] + assert probe.streamable_posts >= 2 + assert probe.streamable_messages[:2] == ['initialize', 'notifications/initialized'] + assert 'tools/list' in probe.streamable_messages + assert probe.sse_gets == 0 + finally: + await _close_session(session) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('status_code', [400, 404, 405]) +async def test_remote_transport_real_streamable_http_error_falls_back_to_legacy_sse(status_code: int): + async with _transport_server(status_code) as (probe, url): + session = _session(url) + try: + await session._init_remote_server() + assert session.session is not None + assert probe.streamable_posts == 1 + assert probe.sse_gets == 1 + assert 'initialize' in probe.sse_messages + finally: + await _close_session(session) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('status_code', [401, 403, 406, 415, 429, 500]) +async def test_remote_transport_real_non_compatibility_error_does_not_fallback(status_code: int): + async with _transport_server(status_code) as (probe, url): + session = _session(url) + try: + with pytest.raises(BaseException) as exc_info: + await session._init_remote_server() + assert _contains_http_status(exc_info.value, status_code) + assert probe.streamable_posts == 1 + assert probe.sse_gets == 0 + finally: + await _close_session(session) + + +@pytest.mark.asyncio +async def test_remote_transport_real_timeout_does_not_fallback(): + async with _transport_server(None) as (probe, url): + session = _session(url, timeout=0.05) + try: + with pytest.raises(BaseException) as exc_info: + await session._init_remote_server() + assert any( + isinstance(leaf, httpx.TimeoutException) + for leaf in RuntimeMCPSession._iter_exception_leaves(exc_info.value) + ) + assert probe.streamable_posts == 1 + assert probe.sse_gets == 0 + finally: + probe.release_streamable_request.set() + await _close_session(session) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('error_type', [httpx.ConnectError, httpx.ConnectTimeout]) +async def test_remote_transport_connection_errors_do_not_fallback(error_type: type[httpx.RequestError]): + request = httpx.Request('POST', 'https://unreachable.invalid/mcp') + error = error_type('connection failed', request=request) + session = _session(str(request.url)) + session._init_streamable_http_server = AsyncMock(side_effect=error) + session._init_sse_server = AsyncMock() + + with pytest.raises(type(error)) as exc_info: + await session._init_remote_server() + + assert exc_info.value is error + session._init_sse_server.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_remote_transport_external_cancellation_is_not_converted_to_sse_fallback(): + async with _transport_server(None) as (probe, url): + session = _session(url) + task = asyncio.create_task(session._init_remote_server()) + await asyncio.wait_for(probe.streamable_request_started.wait(), timeout=2) + task.cancel() + try: + with pytest.raises(asyncio.CancelledError): + await task + assert probe.sse_gets == 0 + finally: + probe.release_streamable_request.set() + await _close_session(session) diff --git a/tests/unit_tests/provider/test_mcp_resources.py b/tests/unit_tests/provider/test_mcp_resources.py new file mode 100644 index 000000000..773efe71c --- /dev/null +++ b/tests/unit_tests/provider/test_mcp_resources.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import base64 +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest +from mcp import types as mcp_types + +from langbot.pkg.provider.tools.loaders.mcp import ( + MCP_RESOURCE_CONTEXT_QUERY_KEY, + MCP_RESOURCE_TRACE_QUERY_KEY, + MCP_TOOL_LIST_RESOURCES, + MCP_TOOL_READ_RESOURCE, + MCPLoader, + MCPSessionStatus, + RuntimeMCPSession, +) +from langbot.pkg.telemetry import features as telemetry_features + + +def _app() -> SimpleNamespace: + return SimpleNamespace(logger=Mock()) + + +def _connected_session( + *, + name: str = 'docs', + uuid: str = 'srv-1', + resources: list[dict] | None = None, + templates: list[dict] | None = None, +) -> RuntimeMCPSession: + session = RuntimeMCPSession(name, {'uuid': uuid, 'mode': 'remote'}, True, _app()) + session.status = MCPSessionStatus.CONNECTED + session.session = SimpleNamespace(read_resource=AsyncMock()) + session.resources = resources or [ + { + 'uri': 'file:///README.md', + 'name': 'README.md', + 'title': '', + 'description': '', + 'mime_type': 'text/markdown', + 'size': None, + 'icons': [], + 'annotations': {}, + '_meta': {}, + } + ] + session.resource_templates = templates or [] + return session + + +def _query() -> SimpleNamespace: + return SimpleNamespace(variables={}) + + +def _http_status_error(status_code: int) -> httpx.HTTPStatusError: + request = httpx.Request('POST', 'https://example.com/mcp') + response = httpx.Response(status_code, request=request) + return httpx.HTTPStatusError(f'HTTP {status_code}', request=request, response=response) + + +@pytest.mark.asyncio +async def test_remote_transport_falls_back_to_sse_for_compatible_http_status_in_exception_group(): + session = RuntimeMCPSession( + 'remote', + {'uuid': 'srv-1', 'mode': 'remote', 'url': 'https://example.com/mcp'}, + True, + _app(), + ) + session._init_streamable_http_server = AsyncMock( + side_effect=ExceptionGroup('transport failed', [_http_status_error(405)]) + ) + session._init_sse_server = AsyncMock() + + await session._init_remote_server() + + session._init_streamable_http_server.assert_awaited_once() + session._init_sse_server.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_remote_transport_does_not_fallback_for_auth_http_status(): + session = RuntimeMCPSession( + 'remote', + {'uuid': 'srv-1', 'mode': 'remote', 'url': 'https://example.com/mcp'}, + True, + _app(), + ) + error = _http_status_error(403) + session._init_streamable_http_server = AsyncMock(side_effect=error) + session._init_sse_server = AsyncMock() + + with pytest.raises(httpx.HTTPStatusError): + await session._init_remote_server() + + session._init_streamable_http_server.assert_awaited_once() + session._init_sse_server.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_read_resource_envelope_truncates_caches_and_records_trace(): + session = _connected_session() + session.session.read_resource.return_value = mcp_types.ReadResourceResult( + contents=[ + mcp_types.TextResourceContents( + uri='file:///README.md', + mimeType='text/markdown', + text='abcdef', + ) + ] + ) + query = _query() + + first = await session.read_resource_envelope( + 'file:///README.md', + max_bytes=4, + source='ui_preview', + query=query, + ) + second = await session.read_resource_envelope( + 'file:///README.md', + max_bytes=4, + source='agent_tool', + query=query, + ) + + assert first['contents'][0]['text'] == 'abcd' + assert first['contents'][0]['bytes'] == 6 + assert first['truncated'] is True + assert first['cache_hit'] is False + assert second['cache_hit'] is True + assert second['source'] == 'agent_tool' + assert session.session.read_resource.await_count == 1 + + traces = query.variables[MCP_RESOURCE_TRACE_QUERY_KEY] + assert [trace['source'] for trace in traces] == ['ui_preview', 'agent_tool'] + assert traces[1]['cache_hit'] is True + assert query.variables[telemetry_features.FEATURES_KEY]['mcp_resource_reads'] == { + 'ui_preview': 1, + 'agent_tool': 1, + } + + +@pytest.mark.asyncio +async def test_read_resource_envelope_shares_byte_budget_across_text_contents(): + session = _connected_session() + session.session.read_resource.return_value = mcp_types.ReadResourceResult( + contents=[ + mcp_types.TextResourceContents( + uri='file:///README.md#first', + mimeType='text/plain', + text='abc', + ), + mcp_types.TextResourceContents( + uri='file:///README.md#second', + mimeType='text/plain', + text='def', + ), + ] + ) + + envelope = await session.read_resource_envelope('file:///README.md', max_bytes=4) + + assert [item['text'] for item in envelope['contents']] == ['abc', 'd'] + assert envelope['contents'][0]['truncated'] is False + assert envelope['contents'][1]['truncated'] is True + assert envelope['bytes'] == 6 + assert envelope['truncated'] is True + + +@pytest.mark.asyncio +async def test_read_resource_envelope_omits_binary_by_default(): + session = _connected_session( + resources=[ + { + 'uri': 'file:///image.png', + 'name': 'image.png', + 'title': '', + 'description': '', + 'mime_type': 'image/png', + 'size': 4, + 'icons': [], + 'annotations': {}, + '_meta': {}, + } + ] + ) + session.session.read_resource.return_value = mcp_types.ReadResourceResult( + contents=[ + mcp_types.BlobResourceContents( + uri='file:///image.png', + mimeType='image/png', + blob=base64.b64encode(b'\x00\x01\x02\x03').decode(), + ) + ] + ) + + envelope = await session.read_resource_envelope('file:///image.png') + + content = envelope['contents'][0] + assert content['type'] == 'blob' + assert content['blob'] is None + assert content['bytes'] == 4 + assert content['binary_omitted'] is True + assert envelope['truncated'] is True + assert envelope['warnings'] == ['Binary resource content omitted from response.'] + + +@pytest.mark.asyncio +async def test_read_resource_envelope_rejects_unlisted_uri(): + session = _connected_session() + + with pytest.raises(ValueError, match='Resource URI is not available'): + await session.read_resource_envelope('file:///secret.txt') + + session.session.read_resource.assert_not_called() + + +def test_resource_uri_allowed_supports_listed_templates_conservatively(): + session = _connected_session( + resources=[], + templates=[ + { + 'uri_template': 'repo://{owner}/{repo}/file/{path}', + 'name': 'repository file', + 'title': '', + 'description': '', + 'mime_type': 'text/plain', + 'icons': [], + 'annotations': {}, + '_meta': {}, + } + ], + ) + + assert session.resource_uri_allowed('repo://langbot-app/LangBot/file/src/main.py') is True + assert session.resource_uri_allowed('repo://langbot-app/LangBot/issues/1') is False + assert session.resource_uri_allowed('https://example.com/secret') is False + + +@pytest.mark.asyncio +async def test_mcp_loader_can_hide_synthetic_resource_tools(): + loader = MCPLoader(_app()) + session = _connected_session() + loader.sessions = {'docs': session} + + with_resource_tools = await loader.get_tools(['srv-1'], include_resource_tools=True) + without_resource_tools = await loader.get_tools(['srv-1'], include_resource_tools=False) + + assert {tool.name for tool in with_resource_tools} == { + MCP_TOOL_LIST_RESOURCES, + MCP_TOOL_READ_RESOURCE, + } + assert without_resource_tools == [] + + +@pytest.mark.asyncio +async def test_mcp_loader_refuses_resource_tool_calls_when_agent_read_disabled(): + loader = MCPLoader(_app()) + session = _connected_session() + loader.sessions = {'docs': session} + query = SimpleNamespace( + variables={ + '_pipeline_bound_mcp_servers': ['srv-1'], + '_pipeline_mcp_resource_agent_read_enabled': False, + } + ) + + result = await loader.invoke_tool( + MCP_TOOL_READ_RESOURCE, + {'server_name': 'docs', 'uri': 'file:///README.md'}, + query, + ) + + assert result[0].text == 'Error: MCP resource agent reads are disabled.' + session.session.read_resource.assert_not_called() + + +@pytest.mark.asyncio +async def test_build_resource_context_for_query_uses_only_bound_attached_text_resources(): + loader = MCPLoader(_app()) + docs = _connected_session(name='docs', uuid='srv-1') + docs.session.read_resource.return_value = mcp_types.ReadResourceResult( + contents=[ + mcp_types.TextResourceContents( + uri='file:///README.md', + mimeType='text/markdown', + text='LangBot MCP resource context', + ) + ] + ) + other = _connected_session(name='other', uuid='srv-2') + other.session.read_resource.return_value = mcp_types.ReadResourceResult( + contents=[ + mcp_types.TextResourceContents( + uri='file:///README.md', + mimeType='text/markdown', + text='must not be injected', + ) + ] + ) + loader.sessions = {'docs': docs, 'other': other} + query = SimpleNamespace( + variables={ + '_pipeline_bound_mcp_servers': ['srv-1'], + '_pipeline_mcp_resource_attachments': [ + {'server_uuid': 'srv-1', 'server_name': 'docs', 'uri': 'file:///README.md', 'mode': 'pinned'}, + {'server_uuid': 'srv-2', 'server_name': 'other', 'uri': 'file:///README.md', 'mode': 'pinned'}, + ], + } + ) + + context = await loader.build_resource_context_for_query(query) + + assert ' LiteLLMRequester: + # _convert_messages and _normalize_stream_tool_calls do not touch instance config. + return LiteLLMRequester.__new__(LiteLLMRequester) + + +def test_convert_messages_preserves_tool_call_provider_specific_fields(): + """Tool calls should retain provider_specific_fields through _convert_messages.""" + req = _make_requester() + msg = provider_message.Message( + role='assistant', + content=None, + tool_calls=[ + provider_message.ToolCall( + id='call_123', + type='function', + function=provider_message.FunctionCall( + name='search', + arguments='{"query": "test"}', + ), + provider_specific_fields={ + 'thought_signature': 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZQ==', + }, + ), + ], + ) + out = req._convert_messages([msg]) + assert len(out) == 1 + assert out[0]['tool_calls'] is not None + assert len(out[0]['tool_calls']) == 1 + + tc = out[0]['tool_calls'][0] + assert tc['id'] == 'call_123' + assert tc['function']['name'] == 'search' + assert 'provider_specific_fields' in tc + assert tc['provider_specific_fields']['thought_signature'] == 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZQ==' + + +def test_convert_messages_preserves_message_provider_specific_fields(): + """Messages should retain provider_specific_fields through _convert_messages.""" + req = _make_requester() + msg = provider_message.Message( + role='assistant', + content='Hello', + provider_specific_fields={ + 'thought_signatures': ['sig1', 'sig2'], + }, + ) + out = req._convert_messages([msg]) + assert len(out) == 1 + assert 'provider_specific_fields' in out[0] + assert out[0]['provider_specific_fields']['thought_signatures'] == ['sig1', 'sig2'] + + +def test_normalize_stream_tool_calls_preserves_provider_specific_fields(): + """Streaming tool calls should retain provider_specific_fields.""" + req = _make_requester() + tool_call_state: dict[int, dict] = {} + + # Simulate first chunk with id and type + raw_tool_calls_1 = [ + { + 'index': 0, + 'id': 'call_abc', + 'type': 'function', + 'function': { + 'name': 'get_weather', + 'arguments': '', + }, + 'provider_specific_fields': { + 'thought_signature': 'dGVzdF9zaWduYXR1cmU=', + }, + }, + ] + result_1 = req._normalize_stream_tool_calls(raw_tool_calls_1, tool_call_state) + assert result_1 is not None + assert len(result_1) == 1 + assert result_1[0]['provider_specific_fields']['thought_signature'] == 'dGVzdF9zaWduYXR1cmU=' + + # Simulate second chunk without provider_specific_fields (should be retained from state) + raw_tool_calls_2 = [ + { + 'index': 0, + 'function': { + 'arguments': '{"city": "Tokyo"}', + }, + }, + ] + result_2 = req._normalize_stream_tool_calls(raw_tool_calls_2, tool_call_state) + assert result_2 is not None + assert len(result_2) == 1 + # Should retain the provider_specific_fields from the first chunk + assert result_2[0]['provider_specific_fields']['thought_signature'] == 'dGVzdF9zaWduYXR1cmU=' + assert result_2[0]['function']['arguments'] == '{"city": "Tokyo"}' + + +def test_normalize_stream_tool_calls_merges_function_level_psf(): + """Function-level provider_specific_fields should be merged into tool-level.""" + req = _make_requester() + tool_call_state: dict[int, dict] = {} + + raw_tool_calls = [ + { + 'index': 0, + 'id': 'call_xyz', + 'type': 'function', + 'function': { + 'name': 'search', + 'arguments': '{}', + 'provider_specific_fields': { + 'thought_signature': 'ZnVuY19sZXZlbF9zaWc=', + }, + }, + }, + ] + result = req._normalize_stream_tool_calls(raw_tool_calls, tool_call_state) + assert result is not None + assert result[0]['provider_specific_fields']['thought_signature'] == 'ZnVuY19sZXZlbF9zaWc=' + + +def test_tool_call_roundtrip_through_message_entity(): + """Full round-trip: LiteLLM response dict -> Message entity -> _convert_messages.""" + # Simulate what LiteLLM returns for a Gemini tool call response + message_data = { + 'role': 'assistant', + 'content': None, + 'tool_calls': [ + { + 'id': 'call_gemini_123', + 'type': 'function', + 'function': { + 'name': 'web_search', + 'arguments': '{"query": "test"}', + }, + 'provider_specific_fields': { + 'thought_signature': 'Z2VtaW5pX3NpZ25hdHVyZQ==', + }, + }, + ], + 'provider_specific_fields': { + 'thought_signatures': ['Z2VtaW5pX3NpZ25hdHVyZQ=='], + }, + } + + # Parse into Message entity (this is what invoke_llm does) + msg = provider_message.Message(**message_data) + + # Verify the entity has the fields + assert msg.tool_calls is not None + assert len(msg.tool_calls) == 1 + assert msg.tool_calls[0].provider_specific_fields is not None + assert msg.tool_calls[0].provider_specific_fields['thought_signature'] == 'Z2VtaW5pX3NpZ25hdHVyZQ==' + assert msg.provider_specific_fields is not None + assert msg.provider_specific_fields['thought_signatures'] == ['Z2VtaW5pX3NpZ25hdHVyZQ=='] + + # Convert back to dict for LiteLLM (this is what _convert_messages does) + req = _make_requester() + out = req._convert_messages([msg]) + + # Verify the fields are preserved in the output + assert out[0]['tool_calls'][0]['provider_specific_fields']['thought_signature'] == 'Z2VtaW5pX3NpZ25hdHVyZQ==' + assert out[0]['provider_specific_fields']['thought_signatures'] == ['Z2VtaW5pX3NpZ25hdHVyZQ=='] diff --git a/tests/unit_tests/provider/test_requester_base.py b/tests/unit_tests/provider/test_requester_base.py index c34556cdb..71c0da653 100644 --- a/tests/unit_tests/provider/test_requester_base.py +++ b/tests/unit_tests/provider/test_requester_base.py @@ -43,6 +43,7 @@ class TestableRequester(requester.ProviderAPIRequester): remove_think=False, ): import langbot_plugin.api.entities.builtin.provider.message as provider_message + return provider_message.Message( role='assistant', content=[provider_message.ContentElement(type='text', text='Testable response')], @@ -289,7 +290,9 @@ async def test_runtime_provider_invoke_llm_delegates(runtime_provider, runtime_l current_stage_name=None, ) - messages = [provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])] + messages = [ + provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')]) + ] result = await provider.invoke_llm(query, runtime_llm_model, messages) @@ -330,7 +333,9 @@ async def test_runtime_provider_invoke_llm_stream_yields_chunks(runtime_provider current_stage_name=None, ) - messages = [provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])] + messages = [ + provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')]) + ] chunks = [] async for chunk in provider.invoke_llm_stream(query, runtime_llm_model, messages): @@ -576,7 +581,9 @@ async def test_runtime_provider_invoke_llm_propagates_error(mock_app_for_modelmg current_stage_name=None, ) - messages = [provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])] + messages = [ + provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')]) + ] with pytest.raises(RequesterError): await provider.invoke_llm(query, model, messages) diff --git a/tests/unit_tests/provider/test_session_manager.py b/tests/unit_tests/provider/test_session_manager.py index 4698bc494..eca8cac8a 100644 --- a/tests/unit_tests/provider/test_session_manager.py +++ b/tests/unit_tests/provider/test_session_manager.py @@ -5,6 +5,7 @@ Tests cover: - Conversation creation with prompts - Session concurrency semaphore """ + from __future__ import annotations import pytest @@ -60,11 +61,7 @@ class TestSessionManagerGetSession: """Create mock app with instance config.""" mock_app = Mock() mock_app.instance_config = Mock() - mock_app.instance_config.data = { - 'concurrency': { - 'session': 5 - } - } + mock_app.instance_config.data = {'concurrency': {'session': 5}} return mock_app @pytest.fixture @@ -173,11 +170,7 @@ class TestSessionManagerGetConversation: """Create mock app with instance config.""" mock_app = Mock() mock_app.instance_config = Mock() - mock_app.instance_config.data = { - 'concurrency': { - 'session': 5 - } - } + mock_app.instance_config.data = {'concurrency': {'session': 5}} return mock_app @pytest.fixture @@ -201,17 +194,13 @@ class TestSessionManagerGetConversation: return query @pytest.mark.asyncio - async def test_creates_conversation_with_prompt( - self, mock_app_with_config, sample_query, sample_session - ): + async def test_creates_conversation_with_prompt(self, mock_app_with_config, sample_query, sample_session): """Test that get_conversation creates conversation with prompt.""" sessionmgr = get_session_module() manager = sessionmgr.SessionManager(mock_app_with_config) - prompt_config = [ - {'role': 'system', 'content': 'You are a helpful assistant.'} - ] + prompt_config = [{'role': 'system', 'content': 'You are a helpful assistant.'}] pipeline_uuid = 'pipeline-123' bot_uuid = 'bot-123' @@ -234,21 +223,15 @@ class TestSessionManagerGetConversation: manager = sessionmgr.SessionManager(mock_app_with_config) - prompt_config = [ - {'role': 'system', 'content': 'You are a helpful assistant.'} - ] + prompt_config = [{'role': 'system', 'content': 'You are a helpful assistant.'}] pipeline_uuid = 'pipeline-123' bot_uuid = 'bot-123' # First call creates conversation - conv1 = await manager.get_conversation( - sample_query, sample_session, prompt_config, pipeline_uuid, bot_uuid - ) + conv1 = await manager.get_conversation(sample_query, sample_session, prompt_config, pipeline_uuid, bot_uuid) # Second call with same pipeline should return same conversation - conv2 = await manager.get_conversation( - sample_query, sample_session, prompt_config, pipeline_uuid, bot_uuid - ) + conv2 = await manager.get_conversation(sample_query, sample_session, prompt_config, pipeline_uuid, bot_uuid) assert conv1 is conv2 assert len(sample_session.conversations) == 1 @@ -262,36 +245,26 @@ class TestSessionManagerGetConversation: manager = sessionmgr.SessionManager(mock_app_with_config) - prompt_config = [ - {'role': 'system', 'content': 'You are a helpful assistant.'} - ] + prompt_config = [{'role': 'system', 'content': 'You are a helpful assistant.'}] # First call with pipeline1 - conv1 = await manager.get_conversation( - sample_query, sample_session, prompt_config, 'pipeline-1', 'bot-1' - ) + conv1 = await manager.get_conversation(sample_query, sample_session, prompt_config, 'pipeline-1', 'bot-1') # Second call with different pipeline should create new conversation - conv2 = await manager.get_conversation( - sample_query, sample_session, prompt_config, 'pipeline-2', 'bot-2' - ) + conv2 = await manager.get_conversation(sample_query, sample_session, prompt_config, 'pipeline-2', 'bot-2') assert conv1 is not conv2 assert len(sample_session.conversations) == 2 assert sample_session.using_conversation is conv2 @pytest.mark.asyncio - async def test_conversation_has_empty_messages( - self, mock_app_with_config, sample_query, sample_session - ): + async def test_conversation_has_empty_messages(self, mock_app_with_config, sample_query, sample_session): """Test that created conversation has empty messages list.""" sessionmgr = get_session_module() manager = sessionmgr.SessionManager(mock_app_with_config) - prompt_config = [ - {'role': 'system', 'content': 'You are a helpful assistant.'} - ] + prompt_config = [{'role': 'system', 'content': 'You are a helpful assistant.'}] conversation = await manager.get_conversation( sample_query, sample_session, prompt_config, 'pipeline-123', 'bot-123' @@ -300,22 +273,17 @@ class TestSessionManagerGetConversation: assert conversation.messages == [] @pytest.mark.asyncio - async def test_prompt_messages_from_config( - self, mock_app_with_config, sample_query, sample_session - ): + async def test_prompt_messages_from_config(self, mock_app_with_config, sample_query, sample_session): """Test that prompt messages are created from prompt_config.""" sessionmgr = get_session_module() manager = sessionmgr.SessionManager(mock_app_with_config) - prompt_config = [ - {'role': 'system', 'content': 'System message'}, - {'role': 'user', 'content': 'User message'} - ] + prompt_config = [{'role': 'system', 'content': 'System message'}, {'role': 'user', 'content': 'User message'}] conversation = await manager.get_conversation( sample_query, sample_session, prompt_config, 'pipeline-123', 'bot-123' ) assert conversation.prompt.name == 'default' - assert len(conversation.prompt.messages) == 2 \ No newline at end of file + assert len(conversation.prompt.messages) == 2 diff --git a/tests/unit_tests/provider/test_skill_tools.py b/tests/unit_tests/provider/test_skill_tools.py new file mode 100644 index 000000000..9db7b945e --- /dev/null +++ b/tests/unit_tests/provider/test_skill_tools.py @@ -0,0 +1,539 @@ +from __future__ import annotations + +import os +import tempfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + + +def _make_ap(logger=None): + ap = SimpleNamespace() + ap.logger = logger or Mock() + ap.persistence_mgr = Mock() + ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[]))) + ap.persistence_mgr.serialize_model = Mock(side_effect=lambda cls, row: row) + return ap + + +def _make_skill_data( + name='test-skill', + instructions='Do something', + package_root='', + entry_file='SKILL.md', + **kwargs, +): + return { + 'name': name, + 'display_name': kwargs.pop('display_name', name), + 'description': kwargs.pop('description', f'Description of {name}'), + 'instructions': instructions, + 'package_root': package_root, + 'entry_file': entry_file, + **kwargs, + } + + +class TestSkillManagerCache: + """The Box runtime is the only source of truth — SkillManager just holds + an in-memory cache populated by ``reload_skills``. There is no local + filesystem reader anymore.""" + + def test_refresh_skill_from_disk_reports_cache_presence(self): + """Box is the only source of truth for skill content. refresh_skill_from_disk + now just reports whether the skill is still in the in-memory cache — + the actual content refresh is driven by SkillService awaiting + ``reload_skills`` after every Box mutation.""" + from langbot.pkg.skill.manager import SkillManager + + ap = _make_ap() + mgr = SkillManager(ap) + + # Empty cache → returns False + assert mgr.refresh_skill_from_disk('test-skill') is False + + # Cache populated → returns True; method does NOT mutate the cache + cached = _make_skill_data(name='test-skill', instructions='Cached') + mgr.skills['test-skill'] = cached + assert mgr.refresh_skill_from_disk('test-skill') is True + assert mgr.skills['test-skill'] is cached + assert mgr.refresh_skill_from_disk('') is False + + @pytest.mark.asyncio + async def test_reload_skills_drops_box_skills_with_missing_package_root(self): + """When LangBot shares a filesystem with Box (local stdio mode) and Box + reports a skill whose package_root is gone from that shared filesystem, + the cache must drop it instead of keeping a stale entry that would later + produce a bad mount.""" + from langbot.pkg.skill.manager import SkillManager + + with tempfile.TemporaryDirectory() as live_dir: + ghost_dir = os.path.join(live_dir, '_does_not_exist') + box_service = SimpleNamespace( + available=True, + shares_filesystem_with_box=True, + list_skills=AsyncMock( + return_value=[ + _make_skill_data(name='alive', package_root=live_dir), + _make_skill_data(name='ghost', package_root=ghost_dir), + ] + ), + ) + + ap = _make_ap() + ap.box_service = box_service + mgr = SkillManager(ap) + + await mgr.reload_skills() + + assert list(mgr.skills) == ['alive'] + # Warning fired with the dropped skill name so operators can see it. + warning_messages = [str(call.args[0]) for call in ap.logger.warning.call_args_list] + assert any('ghost' in msg and 'package_root missing' in msg for msg in warning_messages) + + @pytest.mark.asyncio + async def test_reload_skills_trusts_box_paths_when_filesystem_not_shared(self): + """In separated deployments (Docker Compose, k8s sidecar, + --standalone-box, remote endpoint) the package_root reported by Box + lives on the Box runtime's filesystem and is not resolvable on the + LangBot side. The cache must keep every Box-reported skill rather than + dropping them all via a local isdir() check.""" + from langbot.pkg.skill.manager import SkillManager + + box_service = SimpleNamespace( + available=True, + shares_filesystem_with_box=False, + list_skills=AsyncMock( + return_value=[ + _make_skill_data(name='alpha', package_root='/box/skills/alpha'), + _make_skill_data(name='beta', package_root='/box/skills/beta'), + ] + ), + ) + + ap = _make_ap() + ap.box_service = box_service + mgr = SkillManager(ap) + + await mgr.reload_skills() + + assert sorted(mgr.skills) == ['alpha', 'beta'] + # No skill dropped → no "package_root missing" warning. + warning_messages = [str(call.args[0]) for call in ap.logger.warning.call_args_list] + assert not any('package_root missing' in msg for msg in warning_messages) + + +class TestSkillActivationHelper: + """Skill activation is now Tool-Call based. + + The legacy text-marker mechanism (``[ACTIVATE_SKILL: x]`` detection, + ``build_activation_prompt_for_skills``, ``remove_activation_marker``, + ``prepare_skill_activation``) has been removed. Activation now goes + through ``skill.activation.register_activated_skill``, invoked by the + ``activate`` Tool Call. + """ + + def test_register_activated_skill_records_known_skill(self): + from langbot.pkg.skill.activation import register_activated_skill + from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILLS_KEY + from langbot.pkg.skill.manager import SkillManager + + ap = _make_ap() + mgr = SkillManager(ap) + mgr.skills = { + 'primary': _make_skill_data(name='primary', instructions='Primary instructions'), + } + ap.skill_mgr = mgr + + query = SimpleNamespace(variables={}) + + assert register_activated_skill(ap, query, 'primary') is True + assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'primary'} + assert query.variables[ACTIVATED_SKILLS_KEY]['primary']['name'] == 'primary' + + def test_register_activated_skill_rejects_unknown_skill(self): + from langbot.pkg.skill.activation import register_activated_skill + from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILLS_KEY + from langbot.pkg.skill.manager import SkillManager + + ap = _make_ap() + mgr = SkillManager(ap) + mgr.skills = {'primary': _make_skill_data(name='primary')} + ap.skill_mgr = mgr + + query = SimpleNamespace(variables={}) + + assert register_activated_skill(ap, query, 'missing') is False + assert ACTIVATED_SKILLS_KEY not in query.variables + + def test_register_activated_skill_without_skill_manager_returns_false(self): + from langbot.pkg.skill.activation import register_activated_skill + + ap = _make_ap() # no skill_mgr attribute + query = SimpleNamespace(variables={}) + + assert register_activated_skill(ap, query, 'primary') is False + + +class TestSkillPathHelpers: + def test_get_visible_skills_filters_by_bound_names(self): + from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY, get_visible_skills + + ap = _make_ap() + ap.skill_mgr = SimpleNamespace( + skills={ + 'visible': _make_skill_data(name='visible'), + 'hidden': _make_skill_data(name='hidden'), + } + ) + query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']}) + + result = get_visible_skills(ap, query) + + assert list(result.keys()) == ['visible'] + + def test_restore_activated_skills_uses_caller_provided_names_and_visibility(self): + from langbot.pkg.provider.tools.loaders.skill import ( + ACTIVATED_SKILLS_KEY, + PIPELINE_BOUND_SKILLS_KEY, + get_activated_skill_names, + restore_activated_skills, + ) + + ap = _make_ap() + ap.skill_mgr = SimpleNamespace( + skills={ + 'visible': _make_skill_data(name='visible'), + 'hidden': _make_skill_data(name='hidden'), + } + ) + query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']}) + + restored = restore_activated_skills(ap, query, ['visible', 'hidden', 'visible', '']) + + assert restored == ['visible'] + assert list(query.variables[ACTIVATED_SKILLS_KEY].keys()) == ['visible'] + assert get_activated_skill_names(query) == ['visible'] + + def test_resolve_virtual_skill_path_allows_visible_skill_reads(self): + from langbot.pkg.provider.tools.loaders.skill import ( + PIPELINE_BOUND_SKILLS_KEY, + resolve_virtual_skill_path, + ) + + ap = _make_ap() + ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')}) + query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}) + + skill, rewritten = resolve_virtual_skill_path( + ap, + query, + '/workspace/.skills/demo/SKILL.md', + include_visible=True, + include_activated=False, + ) + + assert skill['name'] == 'demo' + assert rewritten == '/workspace/SKILL.md' + + def test_build_skill_session_id_uses_name_based_identifier(self): + from langbot.pkg.provider.tools.loaders.skill import build_skill_session_id + + with_launcher = build_skill_session_id( + {'name': 'writer'}, + SimpleNamespace(query_id=42, launcher_type='person', launcher_id='123'), + ) + fallback = build_skill_session_id({'name': 'writer'}, SimpleNamespace(query_id=99)) + + assert with_launcher == 'skill-person_123-writer' + assert fallback == 'skill-99-writer' + + def test_should_prepare_skill_python_env_detects_manifests_and_venv(self): + from langbot.pkg.provider.tools.loaders.skill import should_prepare_skill_python_env + + with tempfile.TemporaryDirectory() as tmpdir: + assert should_prepare_skill_python_env(tmpdir) is False + + with open(os.path.join(tmpdir, 'requirements.txt'), 'w', encoding='utf-8') as f: + f.write('requests==2.32.0\n') + assert should_prepare_skill_python_env(tmpdir) is True + + with tempfile.TemporaryDirectory() as tmpdir: + os.makedirs(os.path.join(tmpdir, '.venv')) + assert should_prepare_skill_python_env(tmpdir) is True + + def test_wrap_skill_command_with_python_env_bootstraps_then_runs_command(self): + from langbot.pkg.provider.tools.loaders.skill import wrap_skill_command_with_python_env + + command = wrap_skill_command_with_python_env('python scripts/run.py') + + assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command + assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command + assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command + assert command.rstrip().endswith('python scripts/run.py') + + +class TestSkillToolLoader: + """The skill tool surface is now just ``activate`` + ``register_skill``. + + The legacy CRUD authoring tools (create/list/get/update/delete/ + import_skill_from_directory/reload_skills) were removed; skill CRUD is + handled by SkillService via the HTTP API / web UI instead. + """ + + @pytest.mark.asyncio + async def test_activate_returns_instructions_and_registers_skill(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import ( + ACTIVATE_SKILL_TOOL_NAME, + SkillToolLoader, + ) + from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILLS_KEY + + skill = _make_skill_data(name='demo', package_root='/data/skills/demo', instructions='Step 1') + ap = _make_ap() + ap.skill_mgr = SimpleNamespace( + skills={'demo': skill}, + get_skill_by_name=lambda name: skill if name == 'demo' else None, + ) + + loader = SkillToolLoader(ap) + query = SimpleNamespace(variables={}) + + result = await loader.invoke_tool(ACTIVATE_SKILL_TOOL_NAME, {'skill_name': 'demo'}, query) + + assert result['activated'] is True + assert result['skill_name'] == 'demo' + assert result['mount_path'] == '/workspace/.skills/demo' + assert result['activated_skill_names'] == ['demo'] + assert 'Step 1' in result['content'] + assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'demo'} + + @pytest.mark.asyncio + async def test_activate_unknown_skill_raises(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import ( + ACTIVATE_SKILL_TOOL_NAME, + SkillToolLoader, + ) + + ap = _make_ap() + ap.skill_mgr = SimpleNamespace( + skills={'demo': _make_skill_data(name='demo')}, + get_skill_by_name=lambda name: None, + ) + + loader = SkillToolLoader(ap) + + with pytest.raises(ValueError, match='not found'): + await loader.invoke_tool( + ACTIVATE_SKILL_TOOL_NAME, + {'skill_name': 'ghost'}, + SimpleNamespace(variables={}), + ) + + @pytest.mark.asyncio + async def test_register_skill_scans_directory_and_creates_skill(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import ( + REGISTER_SKILL_TOOL_NAME, + SkillToolLoader, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + repo_dir = os.path.join(tmpdir, 'repo') + os.makedirs(repo_dir) + + ap = _make_ap() + ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True) + ap.skill_service = SimpleNamespace( + scan_directory_async=AsyncMock( + return_value={ + 'name': 'cloned-skill', + 'display_name': 'Cloned Skill', + 'description': 'Imported from clone', + 'instructions': 'Do work', + } + ), + create_skill=AsyncMock( + return_value=_make_skill_data(name='cloned-skill', package_root=os.path.realpath(repo_dir)) + ), + ) + + loader = SkillToolLoader(ap) + result = await loader.invoke_tool( + REGISTER_SKILL_TOOL_NAME, + {'path': '/workspace/repo'}, + SimpleNamespace(), + ) + + ap.skill_service.scan_directory_async.assert_awaited_once_with(os.path.realpath(repo_dir)) + ap.skill_service.create_skill.assert_awaited_once_with( + { + 'name': 'cloned-skill', + 'display_name': 'Cloned Skill', + 'description': 'Imported from clone', + 'instructions': 'Do work', + 'package_root': os.path.realpath(repo_dir), + } + ) + assert result['registered'] is True + assert result['skill_name'] == 'cloned-skill' + assert result['source_path'] == '/workspace/repo' + + @pytest.mark.asyncio + async def test_register_skill_rejects_workspace_escape(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import ( + REGISTER_SKILL_TOOL_NAME, + SkillToolLoader, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + ap = _make_ap() + ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True) + ap.skill_service = SimpleNamespace(scan_directory_async=AsyncMock(), create_skill=AsyncMock()) + + loader = SkillToolLoader(ap) + + with pytest.raises(ValueError, match='escapes the workspace boundary'): + await loader.invoke_tool( + REGISTER_SKILL_TOOL_NAME, + {'path': '/workspace/../../etc'}, + SimpleNamespace(), + ) + + @pytest.mark.asyncio + async def test_register_skill_requires_skill_service(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import ( + REGISTER_SKILL_TOOL_NAME, + SkillToolLoader, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + ap = _make_ap() # no skill_service attribute + ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True) + + loader = SkillToolLoader(ap) + + with pytest.raises(ValueError, match='Skill service not available'): + await loader.invoke_tool( + REGISTER_SKILL_TOOL_NAME, + {'path': '/workspace/foo'}, + SimpleNamespace(), + ) + + @pytest.mark.asyncio + async def test_tools_hidden_when_sandbox_backend_unavailable(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader + + ap = _make_ap() + ap.skill_mgr = SimpleNamespace(skills={}) + ap.box_service = SimpleNamespace( + available=True, + get_status=AsyncMock(return_value={'backend': {'available': False}}), + ) + + loader = SkillToolLoader(ap) + await loader.initialize() + + assert await loader.get_tools() == [] + assert await loader.has_tool('activate') is False + assert await loader.has_tool('register_skill') is False + + @pytest.mark.asyncio + async def test_tools_exposed_when_sandbox_backend_available(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader + + ap = _make_ap() + ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')}) + ap.box_service = SimpleNamespace( + available=True, + get_status=AsyncMock(return_value={'backend': {'available': True}}), + ) + + loader = SkillToolLoader(ap) + await loader.initialize() + + tools = await loader.get_tools() + + assert sorted(tool.name for tool in tools) == ['activate', 'register_skill'] + assert await loader.has_tool('activate') is True + assert await loader.has_tool('register_skill') is True + + +class TestNativeToolLoaderSkillPaths: + @pytest.mark.asyncio + async def test_read_visible_skill_file(self): + from langbot.pkg.provider.tools.loaders.native import NativeToolLoader + from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY + + with tempfile.TemporaryDirectory() as tmpdir: + skill_md = os.path.join(tmpdir, 'SKILL.md') + with open(skill_md, 'w', encoding='utf-8') as f: + f.write('demo instructions') + + ap = _make_ap() + ap.box_service = SimpleNamespace(available=True, default_workspace=tmpdir) + ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)}) + loader = NativeToolLoader(ap) + + result = await loader.invoke_tool( + 'read', + {'path': '/workspace/.skills/demo/SKILL.md'}, + SimpleNamespace(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}), + ) + + assert result['ok'] is True + assert result['content'] == 'demo instructions' + assert result['truncated'] is False + + @pytest.mark.asyncio + async def test_exec_in_activated_skill_mount_rewrites_command_and_refreshes(self): + from langbot.pkg.provider.tools.loaders.native import NativeToolLoader + from langbot.pkg.provider.tools.loaders.skill import register_activated_skill + + with tempfile.TemporaryDirectory() as tmpdir: + ap = _make_ap() + ap.box_service = SimpleNamespace( + available=True, + default_workspace=tmpdir, + execute_tool=AsyncMock(return_value={'ok': True}), + ) + ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock()) + loader = NativeToolLoader(ap) + + query = SimpleNamespace(query_id='q1', launcher_type='person', launcher_id='123', variables={}) + register_activated_skill(query, _make_skill_data(name='demo', package_root=tmpdir)) + + result = await loader.invoke_tool( + 'exec', + { + 'command': 'python /workspace/.skills/demo/scripts/run.py', + 'workdir': '/workspace/.skills/demo', + }, + query, + ) + + assert result['ok'] is True + tool_parameters = ap.box_service.execute_tool.await_args.args[0] + assert tool_parameters['command'] == 'python /workspace/.skills/demo/scripts/run.py' + assert tool_parameters['workdir'] == '/workspace/.skills/demo' + ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with('demo') + + @pytest.mark.asyncio + async def test_write_requires_skill_activation(self): + from langbot.pkg.provider.tools.loaders.native import NativeToolLoader + from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY + + with tempfile.TemporaryDirectory() as tmpdir: + ap = _make_ap() + ap.box_service = SimpleNamespace(available=True, default_workspace=tmpdir) + ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)}) + loader = NativeToolLoader(ap) + + query = SimpleNamespace(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}) + + with pytest.raises(ValueError, match='Skill "demo" is not available at this path'): + await loader.invoke_tool( + 'write', + {'path': '/workspace/.skills/demo/notes.txt', 'content': 'hi'}, + query, + ) diff --git a/tests/unit_tests/provider/test_tool_manager.py b/tests/unit_tests/provider/test_tool_manager.py index 867b2e221..0ae33115c 100644 --- a/tests/unit_tests/provider/test_tool_manager.py +++ b/tests/unit_tests/provider/test_tool_manager.py @@ -1,9 +1,10 @@ """Unit tests for ToolManager. Tests cover: -- Tool schema generation for OpenAI and Anthropic +- Tool schema generation for OpenAI/LiteLLM - Tool execution dispatch """ + from __future__ import annotations import pytest @@ -52,11 +53,12 @@ class TestToolManagerSchemaGeneration: @pytest.fixture def sample_tools(self): """Create sample LLMTool list for testing.""" + def dummy_weather_func(**kwargs): - return "weather result" + return 'weather result' def dummy_calc_func(**kwargs): - return "calc result" + return 'calc result' tools = [ resource_tool.LLMTool( @@ -65,15 +67,10 @@ class TestToolManagerSchemaGeneration: description='Get current weather for a location', parameters={ 'type': 'object', - 'properties': { - 'location': { - 'type': 'string', - 'description': 'City name' - } - }, - 'required': ['location'] + 'properties': {'location': {'type': 'string', 'description': 'City name'}}, + 'required': ['location'], }, - func=dummy_weather_func + func=dummy_weather_func, ), resource_tool.LLMTool( name='calculate', @@ -81,15 +78,10 @@ class TestToolManagerSchemaGeneration: description='Perform a calculation', parameters={ 'type': 'object', - 'properties': { - 'expression': { - 'type': 'string', - 'description': 'Math expression' - } - }, - 'required': ['expression'] + 'properties': {'expression': {'type': 'string', 'description': 'Math expression'}}, + 'required': ['expression'], }, - func=dummy_calc_func + func=dummy_calc_func, ), ] return tools @@ -117,28 +109,6 @@ class TestToolManagerSchemaGeneration: assert tool2['type'] == 'function' assert tool2['function']['name'] == 'calculate' - @pytest.mark.asyncio - async def test_generate_tools_for_anthropic(self, mock_app, sample_tools): - """Test that generate_tools_for_anthropic produces correct schema.""" - toolmgr = get_toolmgr_module() - - manager = toolmgr.ToolManager(mock_app) - result = await manager.generate_tools_for_anthropic(sample_tools) - - assert len(result) == 2 - - # Verify first tool schema (Anthropic format) - tool1 = result[0] - assert tool1['name'] == 'get_weather' - assert tool1['description'] == 'Get current weather for a location' - assert 'input_schema' in tool1 - assert tool1['input_schema']['type'] == 'object' - - # Verify second tool schema - tool2 = result[1] - assert tool2['name'] == 'calculate' - assert 'input_schema' in tool2 - @pytest.mark.asyncio async def test_generate_tools_empty_list(self, mock_app): """Test that generating tools from empty list returns empty list.""" @@ -149,9 +119,6 @@ class TestToolManagerSchemaGeneration: openai_result = await manager.generate_tools_for_openai([]) assert openai_result == [] - anthropic_result = await manager.generate_tools_for_anthropic([]) - assert anthropic_result == [] - @pytest.mark.asyncio async def test_openai_schema_fields_complete(self, mock_app, sample_tools): """Test that OpenAI schema includes all required fields.""" @@ -169,45 +136,54 @@ class TestToolManagerSchemaGeneration: assert 'description' in func assert 'parameters' in func - @pytest.mark.asyncio - async def test_anthropic_schema_fields_complete(self, mock_app, sample_tools): - """Test that Anthropic schema includes all required fields.""" - toolmgr = get_toolmgr_module() - - manager = toolmgr.ToolManager(mock_app) - result = await manager.generate_tools_for_anthropic(sample_tools) - - for tool_schema in result: - assert 'name' in tool_schema - assert 'description' in tool_schema - assert 'input_schema' in tool_schema - class TestToolManagerExecuteFuncCall: """Tests for execute_func_call method.""" @pytest.fixture def mock_app_with_loaders(self): - """Create mock app with mock tool loaders.""" + """Create mock app with mock tool loaders. + + Returns (app, plugin_loader, mcp_loader). The native and skill loaders + are attached directly to the app for tests that don't need to assert + against them — they all default to ``has_tool == False`` so the + execute_func_call probe falls through to the plugin/mcp pair. + """ mock_app = Mock() mock_app.logger = Mock() + def _make_inert_loader(): + loader = Mock() + loader.has_tool = AsyncMock(return_value=False) + loader.invoke_tool = AsyncMock(return_value=None) + loader.initialize = AsyncMock() + loader.shutdown = AsyncMock() + return loader + # Create mock plugin loader - mock_plugin_loader = Mock() - mock_plugin_loader.has_tool = AsyncMock(return_value=False) + mock_plugin_loader = _make_inert_loader() mock_plugin_loader.invoke_tool = AsyncMock(return_value='plugin_result') - mock_plugin_loader.initialize = AsyncMock() - mock_plugin_loader.shutdown = AsyncMock() # Create mock MCP loader - mock_mcp_loader = Mock() - mock_mcp_loader.has_tool = AsyncMock(return_value=False) + mock_mcp_loader = _make_inert_loader() mock_mcp_loader.invoke_tool = AsyncMock(return_value='mcp_result') - mock_mcp_loader.initialize = AsyncMock() - mock_mcp_loader.shutdown = AsyncMock() + + # Stash inert native/skill loaders so the ToolManager probe order + # (native → plugin → mcp → skill) doesn't AttributeError. Tests that + # need to override these can replace the attributes on the manager. + mock_app._inert_native_loader = _make_inert_loader() + mock_app._inert_skill_loader = _make_inert_loader() return mock_app, mock_plugin_loader, mock_mcp_loader + @staticmethod + def _wire_loaders(manager, mock_app, plugin_loader, mcp_loader): + """Attach all four loaders (native + plugin + mcp + skill) to manager.""" + manager.native_tool_loader = mock_app._inert_native_loader + manager.plugin_tool_loader = plugin_loader + manager.mcp_tool_loader = mcp_loader + manager.skill_tool_loader = mock_app._inert_skill_loader + @pytest.fixture def sample_query(self): """Create sample query for testing.""" @@ -215,9 +191,7 @@ class TestToolManagerExecuteFuncCall: return query @pytest.mark.asyncio - async def test_execute_calls_plugin_loader_when_has_tool( - self, mock_app_with_loaders, sample_query - ): + async def test_execute_calls_plugin_loader_when_has_tool(self, mock_app_with_loaders, sample_query): """Test that execute_func_call uses plugin loader when tool exists there.""" toolmgr = get_toolmgr_module() @@ -225,26 +199,17 @@ class TestToolManagerExecuteFuncCall: mock_plugin_loader.has_tool = AsyncMock(return_value=True) manager = toolmgr.ToolManager(mock_app) - manager.plugin_tool_loader = mock_plugin_loader - manager.mcp_tool_loader = mock_mcp_loader + self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader) - result = await manager.execute_func_call( - 'test_tool', - {'param': 'value'}, - sample_query - ) + result = await manager.execute_func_call('test_tool', {'param': 'value'}, sample_query) assert result == 'plugin_result' - mock_plugin_loader.invoke_tool.assert_called_once_with( - 'test_tool', {'param': 'value'}, sample_query - ) + mock_plugin_loader.invoke_tool.assert_called_once_with('test_tool', {'param': 'value'}, sample_query) # MCP loader should not be called mock_mcp_loader.invoke_tool.assert_not_called() @pytest.mark.asyncio - async def test_execute_calls_mcp_loader_when_plugin_not_found( - self, mock_app_with_loaders, sample_query - ): + async def test_execute_calls_mcp_loader_when_plugin_not_found(self, mock_app_with_loaders, sample_query): """Test that execute_func_call uses MCP loader when plugin doesn't have tool.""" toolmgr = get_toolmgr_module() @@ -253,25 +218,16 @@ class TestToolManagerExecuteFuncCall: mock_mcp_loader.has_tool = AsyncMock(return_value=True) manager = toolmgr.ToolManager(mock_app) - manager.plugin_tool_loader = mock_plugin_loader - manager.mcp_tool_loader = mock_mcp_loader + self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader) - result = await manager.execute_func_call( - 'test_tool', - {'param': 'value'}, - sample_query - ) + result = await manager.execute_func_call('test_tool', {'param': 'value'}, sample_query) assert result == 'mcp_result' - mock_mcp_loader.invoke_tool.assert_called_once_with( - 'test_tool', {'param': 'value'}, sample_query - ) + mock_mcp_loader.invoke_tool.assert_called_once_with('test_tool', {'param': 'value'}, sample_query) @pytest.mark.asyncio - async def test_execute_raises_when_tool_not_found( - self, mock_app_with_loaders, sample_query - ): - """Test that execute_func_call raises ValueError when tool not found.""" + async def test_execute_raises_when_tool_not_found(self, mock_app_with_loaders, sample_query): + """Test that execute_func_call raises ToolNotFoundError when tool not found.""" toolmgr = get_toolmgr_module() mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders @@ -279,20 +235,13 @@ class TestToolManagerExecuteFuncCall: mock_mcp_loader.has_tool = AsyncMock(return_value=False) manager = toolmgr.ToolManager(mock_app) - manager.plugin_tool_loader = mock_plugin_loader - manager.mcp_tool_loader = mock_mcp_loader + self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader) - with pytest.raises(ValueError, match='未找到工具'): - await manager.execute_func_call( - 'unknown_tool', - {}, - sample_query - ) + with pytest.raises(toolmgr.ToolNotFoundError, match='Tool not found: unknown_tool'): + await manager.execute_func_call('unknown_tool', {}, sample_query) @pytest.mark.asyncio - async def test_plugin_loader_checked_first( - self, mock_app_with_loaders, sample_query - ): + async def test_plugin_loader_checked_first(self, mock_app_with_loaders, sample_query): """Test that plugin loader is checked before MCP loader.""" toolmgr = get_toolmgr_module() @@ -302,8 +251,7 @@ class TestToolManagerExecuteFuncCall: mock_mcp_loader.has_tool = AsyncMock(return_value=True) manager = toolmgr.ToolManager(mock_app) - manager.plugin_tool_loader = mock_plugin_loader - manager.mcp_tool_loader = mock_mcp_loader + self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader) await manager.execute_func_call('test_tool', {}, sample_query) @@ -317,20 +265,30 @@ class TestToolManagerShutdown: @pytest.mark.asyncio async def test_shutdown_calls_loader_shutdown(self): - """Test that shutdown calls shutdown on both loaders.""" + """Test that shutdown calls shutdown on every registered loader.""" toolmgr = get_toolmgr_module() mock_app = Mock() - mock_plugin_loader = Mock() - mock_plugin_loader.shutdown = AsyncMock() - mock_mcp_loader = Mock() - mock_mcp_loader.shutdown = AsyncMock() + + def _make_loader(): + loader = Mock() + loader.shutdown = AsyncMock() + return loader + + mock_native_loader = _make_loader() + mock_plugin_loader = _make_loader() + mock_mcp_loader = _make_loader() + mock_skill_loader = _make_loader() manager = toolmgr.ToolManager(mock_app) + manager.native_tool_loader = mock_native_loader manager.plugin_tool_loader = mock_plugin_loader manager.mcp_tool_loader = mock_mcp_loader + manager.skill_tool_loader = mock_skill_loader await manager.shutdown() + mock_native_loader.shutdown.assert_called_once() mock_plugin_loader.shutdown.assert_called_once() - mock_mcp_loader.shutdown.assert_called_once() \ No newline at end of file + mock_mcp_loader.shutdown.assert_called_once() + mock_skill_loader.shutdown.assert_called_once() diff --git a/tests/unit_tests/provider/test_tool_manager_native.py b/tests/unit_tests/provider/test_tool_manager_native.py new file mode 100644 index 000000000..2085a9e8c --- /dev/null +++ b/tests/unit_tests/provider/test_tool_manager_native.py @@ -0,0 +1,499 @@ +from __future__ import annotations + +import base64 +import os +import tempfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +import langbot_plugin.api.entities.builtin.resource.tool as resource_tool + +from langbot.pkg.provider.tools.loaders.native import NativeToolLoader +from langbot.pkg.provider.tools.toolmgr import ToolManager + + +class StubLoader: + def __init__( + self, + tools: list[resource_tool.LLMTool] | None = None, + invoke_result=None, + catalog_source: str = 'mcp', + catalog_source_name: str = 'fixture-server', + ): + self._tools = tools or [] + self._invoke_result = invoke_result + self._catalog_source = catalog_source + self._catalog_source_name = catalog_source_name + + async def get_tools(self, *_args, **_kwargs): + return self._tools + + async def get_tool_catalog(self, *_args, **_kwargs): + return [ + { + 'name': tool.name, + 'description': tool.description, + 'human_desc': tool.human_desc, + 'parameters': tool.parameters, + 'source': self._catalog_source, + 'source_name': self._catalog_source_name, + 'source_id': self._catalog_source_name, + } + for tool in self._tools + ] + + async def has_tool(self, name: str) -> bool: + return any(tool.name == name for tool in self._tools) + + async def invoke_tool(self, name: str, parameters: dict, query): + return self._invoke_result(name, parameters, query) if callable(self._invoke_result) else self._invoke_result + + async def shutdown(self): + return None + + +def make_tool(name: str) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=name, + human_desc=name, + description=name, + parameters={'type': 'object', 'properties': {}}, + func=lambda parameters: parameters, + ) + + +@pytest.mark.asyncio +async def test_tool_manager_omits_skill_authoring_tools_by_default(): + manager = ToolManager(SimpleNamespace()) + manager.native_tool_loader = StubLoader([make_tool('exec')]) + manager.skill_tool_loader = StubLoader([make_tool('activate')]) + manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')]) + manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')]) + + tools = await manager.get_all_tools() + + assert [tool.name for tool in tools] == ['exec', 'plugin_tool', 'mcp_tool'] + + +@pytest.mark.asyncio +async def test_tool_manager_includes_skill_authoring_tools_when_requested(): + manager = ToolManager(SimpleNamespace()) + manager.native_tool_loader = StubLoader([make_tool('exec')]) + manager.skill_tool_loader = StubLoader([make_tool('activate')]) + manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')]) + manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')]) + + tools = await manager.get_all_tools(include_skill_authoring=True) + + assert [tool.name for tool in tools] == ['exec', 'activate', 'plugin_tool', 'mcp_tool'] + + +@pytest.mark.asyncio +async def test_tool_manager_catalog_labels_tool_sources(): + manager = ToolManager(SimpleNamespace()) + manager.native_tool_loader = StubLoader([make_tool('exec')]) + manager.skill_tool_loader = StubLoader([make_tool('activate')]) + manager.plugin_tool_loader = StubLoader( + [make_tool('plugin_tool')], + catalog_source='plugin', + catalog_source_name='fixture-plugin', + ) + manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')]) + + catalog = await manager.get_tool_catalog(include_skill_authoring=True) + + assert [(item['name'], item['source'], item['source_name']) for item in catalog] == [ + ('exec', 'builtin', 'LangBot'), + ('activate', 'skill', 'LangBot'), + ('plugin_tool', 'plugin', 'fixture-plugin'), + ('mcp_tool', 'mcp', 'fixture-server'), + ] + + +@pytest.mark.asyncio +async def test_tool_manager_routes_native_tool_calls(): + app = SimpleNamespace() + manager = ToolManager(app) + manager.native_tool_loader = StubLoader([make_tool('exec')], invoke_result={'backend': 'fake'}) + manager.skill_tool_loader = StubLoader([make_tool('activate')]) + manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')]) + manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')]) + + result = await manager.execute_func_call('exec', {'command': 'pwd'}, query=Mock()) + + assert result == {'backend': 'fake'} + + +@pytest.mark.asyncio +async def test_native_tool_loader_hides_tools_when_box_unavailable(): + loader = NativeToolLoader(SimpleNamespace(box_service=SimpleNamespace(available=False))) + + assert await loader.get_tools() == [] + for tool_name in ('exec', 'read', 'write', 'edit', 'glob', 'grep'): + assert await loader.has_tool(tool_name) is False + + +@pytest.mark.asyncio +async def test_native_tool_loader_exposes_all_tools_when_box_available(): + box_service = SimpleNamespace( + available=True, + get_status=AsyncMock(return_value={'backend': {'available': True}}), + ) + loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock())) + await loader.initialize() + + tools = await loader.get_tools() + + assert [tool.name for tool in tools] == ['exec', 'read', 'write', 'edit', 'glob', 'grep'] + for tool_name in ('exec', 'read', 'write', 'edit', 'glob', 'grep'): + assert await loader.has_tool(tool_name) is True + + +# ── read/write/edit file tool tests ───────────────────────────── + + +def _make_loader_with_workspace(tmpdir: str) -> tuple[NativeToolLoader, Mock]: + logger = Mock() + box_service = SimpleNamespace(available=True, default_workspace=tmpdir) + ap = SimpleNamespace(box_service=box_service, logger=logger) + return NativeToolLoader(ap), logger + + +def _make_query() -> Mock: + q = Mock() + q.query_id = 'test-query-1' + return q + + +@pytest.mark.asyncio +async def test_read_file(): + with tempfile.TemporaryDirectory() as tmpdir: + loader, _ = _make_loader_with_workspace(tmpdir) + with open(os.path.join(tmpdir, 'hello.txt'), 'w') as f: + f.write('hello world') + + result = await loader.invoke_tool('read', {'path': '/workspace/hello.txt'}, _make_query()) + + assert result['ok'] is True + assert result['content'] == 'hello world' + + +@pytest.mark.asyncio +async def test_read_nonexistent_file(): + with tempfile.TemporaryDirectory() as tmpdir: + loader, _ = _make_loader_with_workspace(tmpdir) + + result = await loader.invoke_tool('read', {'path': '/workspace/no_such.txt'}, _make_query()) + + assert result['ok'] is False + assert 'not found' in result['error'].lower() + + +@pytest.mark.asyncio +async def test_read_directory(): + with tempfile.TemporaryDirectory() as tmpdir: + loader, _ = _make_loader_with_workspace(tmpdir) + os.makedirs(os.path.join(tmpdir, 'subdir')) + with open(os.path.join(tmpdir, 'a.txt'), 'w') as f: + f.write('a') + + result = await loader.invoke_tool('read', {'path': '/workspace'}, _make_query()) + + assert result['ok'] is True + assert result['is_directory'] is True + assert 'a.txt' in result['content'] + + +@pytest.mark.asyncio +async def test_write_creates_file(): + with tempfile.TemporaryDirectory() as tmpdir: + loader, _ = _make_loader_with_workspace(tmpdir) + + result = await loader.invoke_tool( + 'write', {'path': '/workspace/new.txt', 'content': 'new content'}, _make_query() + ) + + assert result['ok'] is True + with open(os.path.join(tmpdir, 'new.txt')) as f: + assert f.read() == 'new content' + + +@pytest.mark.asyncio +async def test_write_creates_subdirectories(): + with tempfile.TemporaryDirectory() as tmpdir: + loader, _ = _make_loader_with_workspace(tmpdir) + + result = await loader.invoke_tool( + 'write', {'path': '/workspace/sub/deep/file.txt', 'content': 'nested'}, _make_query() + ) + + assert result['ok'] is True + with open(os.path.join(tmpdir, 'sub', 'deep', 'file.txt')) as f: + assert f.read() == 'nested' + + +@pytest.mark.asyncio +async def test_read_binary_file_as_base64_chunk(): + with tempfile.TemporaryDirectory() as tmpdir: + loader, _ = _make_loader_with_workspace(tmpdir) + with open(os.path.join(tmpdir, 'blob.bin'), 'wb') as f: + f.write(b'\x00\x01\x02\x03\x04') + + result = await loader.invoke_tool( + 'read', + { + 'path': '/workspace/blob.bin', + 'encoding': 'base64', + 'byte_offset': 1, + 'max_bytes': 2, + }, + _make_query(), + ) + + assert result['ok'] is True + assert result['content'] == base64.b64encode(b'\x01\x02').decode('ascii') + assert result['encoding'] == 'base64' + assert result['byte_offset'] == 1 + assert result['length'] == 2 + assert result['size_bytes'] == 5 + assert result['has_more'] is True + assert result['next_byte_offset'] == 3 + + +@pytest.mark.asyncio +async def test_write_base64_file_append(): + with tempfile.TemporaryDirectory() as tmpdir: + loader, _ = _make_loader_with_workspace(tmpdir) + + first = base64.b64encode(b'\x00\x01').decode('ascii') + second = base64.b64encode(b'\x02\x03').decode('ascii') + await loader.invoke_tool( + 'write', + {'path': '/workspace/blob.bin', 'content': first, 'encoding': 'base64'}, + _make_query(), + ) + result = await loader.invoke_tool( + 'write', + { + 'path': '/workspace/blob.bin', + 'content': second, + 'encoding': 'base64', + 'mode': 'append', + }, + _make_query(), + ) + + assert result['ok'] is True + with open(os.path.join(tmpdir, 'blob.bin'), 'rb') as f: + assert f.read() == b'\x00\x01\x02\x03' + + +@pytest.mark.asyncio +async def test_write_base64_rejects_invalid_content(): + with tempfile.TemporaryDirectory() as tmpdir: + loader, _ = _make_loader_with_workspace(tmpdir) + + result = await loader.invoke_tool( + 'write', + {'path': '/workspace/blob.bin', 'content': 'not base64!', 'encoding': 'base64'}, + _make_query(), + ) + + assert result['ok'] is False + assert 'invalid base64' in result['error'] + assert not os.path.exists(os.path.join(tmpdir, 'blob.bin')) + + +@pytest.mark.asyncio +async def test_edit_replaces_unique_string(): + with tempfile.TemporaryDirectory() as tmpdir: + loader, _ = _make_loader_with_workspace(tmpdir) + with open(os.path.join(tmpdir, 'code.py'), 'w') as f: + f.write('def foo():\n return 1\n') + + result = await loader.invoke_tool( + 'edit', + {'path': '/workspace/code.py', 'old_string': 'return 1', 'new_string': 'return 42'}, + _make_query(), + ) + + assert result['ok'] is True + with open(os.path.join(tmpdir, 'code.py')) as f: + assert f.read() == 'def foo():\n return 42\n' + + +@pytest.mark.asyncio +async def test_edit_rejects_ambiguous_match(): + with tempfile.TemporaryDirectory() as tmpdir: + loader, _ = _make_loader_with_workspace(tmpdir) + with open(os.path.join(tmpdir, 'dup.txt'), 'w') as f: + f.write('aaa\naaa\n') + + result = await loader.invoke_tool( + 'edit', + {'path': '/workspace/dup.txt', 'old_string': 'aaa', 'new_string': 'bbb'}, + _make_query(), + ) + + assert result['ok'] is False + assert '2' in result['error'] + + +@pytest.mark.asyncio +async def test_edit_rejects_missing_string(): + with tempfile.TemporaryDirectory() as tmpdir: + loader, _ = _make_loader_with_workspace(tmpdir) + with open(os.path.join(tmpdir, 'x.txt'), 'w') as f: + f.write('hello') + + result = await loader.invoke_tool( + 'edit', + {'path': '/workspace/x.txt', 'old_string': 'nope', 'new_string': 'yes'}, + _make_query(), + ) + + assert result['ok'] is False + assert 'not found' in result['error'].lower() + + +@pytest.mark.asyncio +async def test_path_escape_blocked(): + with tempfile.TemporaryDirectory() as tmpdir: + loader, _ = _make_loader_with_workspace(tmpdir) + + with pytest.raises(ValueError, match='escapes'): + await loader.invoke_tool('read', {'path': '/workspace/../../etc/passwd'}, _make_query()) + + +@pytest.mark.asyncio +async def test_box_availability_helper_handles_unavailable_and_errors(): + from langbot.pkg.provider.tools.loaders.availability import is_box_backend_available + + assert await is_box_backend_available(SimpleNamespace()) is False + assert await is_box_backend_available(SimpleNamespace(box_service=SimpleNamespace(available=False))) is False + + unavailable_backend = SimpleNamespace( + available=True, + get_status=AsyncMock(return_value={'backend': {'available': False}}), + ) + assert await is_box_backend_available(SimpleNamespace(box_service=unavailable_backend)) is False + + failing_backend = SimpleNamespace( + available=True, + get_status=AsyncMock(side_effect=RuntimeError('box unavailable')), + ) + assert await is_box_backend_available(SimpleNamespace(box_service=failing_backend)) is False + + +@pytest.mark.asyncio +async def test_read_file_supports_offset_limit_and_truncation_metadata(): + with tempfile.TemporaryDirectory() as tmpdir: + loader, _ = _make_loader_with_workspace(tmpdir) + with open(os.path.join(tmpdir, 'lines.txt'), 'w', encoding='utf-8') as f: + f.write('one\ntwo\nthree\nfour\n') + + result = await loader.invoke_tool( + 'read', + {'path': '/workspace/lines.txt', 'offset': 2, 'limit': 2}, + _make_query(), + ) + + assert result == { + 'ok': True, + 'content': 'two\nthree', + 'truncated': True, + 'truncated_by': 'lines', + 'start_line': 2, + 'end_line': 3, + 'next_offset': 4, + 'max_lines': 2, + 'max_bytes': 50 * 1024, + } + + +@pytest.mark.asyncio +async def test_read_file_handles_line_larger_than_byte_limit(): + with tempfile.TemporaryDirectory() as tmpdir: + loader, _ = _make_loader_with_workspace(tmpdir) + with open(os.path.join(tmpdir, 'long-line.txt'), 'w', encoding='utf-8') as f: + f.write('abcdef\n') + + result = await loader.invoke_tool( + 'read', + {'path': '/workspace/long-line.txt', 'max_bytes': 3}, + _make_query(), + ) + + assert result['ok'] is True + assert result['truncated'] is True + assert result['truncated_by'] == 'bytes' + assert result['next_offset'] == 1 + assert 'exceeds the 3B read limit' in result['content'] + + +@pytest.mark.asyncio +async def test_exec_result_is_capped_and_exposes_preview_metadata(): + with tempfile.TemporaryDirectory() as tmpdir: + box_service = SimpleNamespace( + available=True, + default_workspace=tmpdir, + execute_tool=AsyncMock( + return_value={ + 'ok': True, + 'stdout': 'a' * 60000, + 'stderr': 'b' * 60000, + 'exit_code': 0, + } + ), + ) + loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock())) + + result = await loader.invoke_tool('exec', {'command': 'python -V'}, _make_query()) + + assert result['ok'] is True + assert len(result['stdout'].encode('utf-8')) == 50 * 1024 + assert len(result['stderr'].encode('utf-8')) == 50 * 1024 + assert len(result['preview'].encode('utf-8')) == 50 * 1024 + assert result['stdout_truncated'] is True + assert result['stderr_truncated'] is True + assert result['truncated'] is True + assert result['truncated_by'] == 'bytes' + + +@pytest.mark.asyncio +async def test_glob_caps_match_count_and_returns_preview(): + with tempfile.TemporaryDirectory() as tmpdir: + loader, _ = _make_loader_with_workspace(tmpdir) + for index in range(105): + with open(os.path.join(tmpdir, f'file-{index:03d}.txt'), 'w', encoding='utf-8') as f: + f.write(str(index)) + + result = await loader.invoke_tool('glob', {'path': '/workspace', 'pattern': '*.txt'}, _make_query()) + + assert result['ok'] is True + assert result['total'] == 105 + assert len(result['matches']) == 100 + assert result['preview'] == '\n'.join(result['matches']) + assert result['truncated'] is True + assert result['truncated_by'] == 'matches' + + +@pytest.mark.asyncio +async def test_grep_reports_invalid_regex_and_truncates_long_matching_lines(): + with tempfile.TemporaryDirectory() as tmpdir: + loader, _ = _make_loader_with_workspace(tmpdir) + with open(os.path.join(tmpdir, 'data.txt'), 'w', encoding='utf-8') as f: + f.write('needle ' + ('x' * 600) + '\n') + + invalid = await loader.invoke_tool('grep', {'path': '/workspace', 'pattern': '['}, _make_query()) + result = await loader.invoke_tool('grep', {'path': '/workspace', 'pattern': 'needle'}, _make_query()) + + assert invalid['ok'] is False + assert 'Invalid regex' in invalid['error'] + assert result['ok'] is True + assert result['truncated'] is True + assert result['truncated_by'] == 'line' + assert result['matches'][0]['file'] == '/workspace/data.txt' + assert result['matches'][0]['content'].endswith('... [truncated]') diff --git a/tests/unit_tests/rag/test_i18n_conversion.py b/tests/unit_tests/rag/test_i18n_conversion.py index a4604e656..203f43454 100644 --- a/tests/unit_tests/rag/test_i18n_conversion.py +++ b/tests/unit_tests/rag/test_i18n_conversion.py @@ -3,6 +3,7 @@ Tests cover: - _to_i18n_name() static method """ + from __future__ import annotations from importlib import import_module @@ -60,4 +61,4 @@ class TestToI18nName: kbmgr = get_kbmgr_module() input_dict = {'en_US': 'English', 'extra_key': 'extra_value'} result = kbmgr.RAGManager._to_i18n_name(input_dict) - assert result == {'en_US': 'English', 'extra_key': 'extra_value'} \ No newline at end of file + assert result == {'en_US': 'English', 'extra_key': 'extra_value'} diff --git a/tests/unit_tests/rag/test_kbmgr.py b/tests/unit_tests/rag/test_kbmgr.py index ae044ebe8..a1a16118d 100644 --- a/tests/unit_tests/rag/test_kbmgr.py +++ b/tests/unit_tests/rag/test_kbmgr.py @@ -6,6 +6,7 @@ Tests cover: - Knowledge engine enrichment - KB loading and removal """ + from __future__ import annotations import pytest @@ -101,13 +102,9 @@ class TestRAGManagerCreateKnowledgeBase: rag_module = get_rag_module() mock_app = create_mock_app() - mock_app.plugin_connector.list_knowledge_engines = AsyncMock( - return_value=[{'plugin_id': 'author/engine'}] - ) + mock_app.plugin_connector.list_knowledge_engines = AsyncMock(return_value=[{'plugin_id': 'author/engine'}]) mock_app.persistence_mgr.execute_async = AsyncMock() - mock_app.plugin_connector.rag_on_kb_create = AsyncMock( - side_effect=Exception('Plugin error') - ) + mock_app.plugin_connector.rag_on_kb_create = AsyncMock(side_effect=Exception('Plugin error')) manager = rag_module.RAGManager(mock_app) @@ -128,9 +125,7 @@ class TestRAGManagerCreateKnowledgeBase: rag_module = get_rag_module() mock_app = create_mock_app() - mock_app.plugin_connector.list_knowledge_engines = AsyncMock( - return_value=[{'plugin_id': 'author/engine'}] - ) + mock_app.plugin_connector.list_knowledge_engines = AsyncMock(return_value=[{'plugin_id': 'author/engine'}]) mock_app.persistence_mgr.execute_async = AsyncMock() mock_app.plugin_connector.rag_on_kb_create = AsyncMock() @@ -206,9 +201,7 @@ class TestRuntimeKnowledgeBaseOnKBCreate: mock_app = create_mock_app() mock_kb = create_mock_kb_entity() - mock_app.plugin_connector.rag_on_kb_create = AsyncMock( - side_effect=Exception('Plugin failed') - ) + mock_app.plugin_connector.rag_on_kb_create = AsyncMock(side_effect=Exception('Plugin failed')) runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb) @@ -245,9 +238,7 @@ class TestRuntimeKnowledgeBaseIngestDocument: mock_app = create_mock_app() mock_kb = create_mock_kb_entity() - mock_app.plugin_connector.call_rag_ingest = AsyncMock( - return_value={'status': 'success'} - ) + mock_app.plugin_connector.call_rag_ingest = AsyncMock(return_value={'status': 'success'}) runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb) @@ -304,14 +295,10 @@ class TestRAGManagerLoadKnowledgeBasesFromDB: # KB that will cause initialize to fail mock_kb = create_mock_kb_entity() - mock_app.persistence_mgr.execute_async = AsyncMock( - return_value=Mock(all=Mock(return_value=[mock_kb])) - ) + mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[mock_kb]))) # Make initialize fail by having plugin_connector throw error - mock_app.plugin_connector.rag_on_kb_create = AsyncMock( - side_effect=Exception('Init failed') - ) + mock_app.plugin_connector.rag_on_kb_create = AsyncMock(side_effect=Exception('Init failed')) manager = rag_module.RAGManager(mock_app) # Should not raise - errors are caught @@ -411,9 +398,7 @@ class TestRuntimeKnowledgeBaseRetrieve: mock_kb = create_mock_kb_entity() mock_kb.retrieval_settings = {} - mock_app.plugin_connector.call_rag_retrieve = AsyncMock( - return_value={'results': []} - ) + mock_app.plugin_connector.call_rag_retrieve = AsyncMock(return_value={'results': []}) runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb) @@ -682,9 +667,7 @@ class TestRAGManagerGetAllDetails: """Test returns empty list when no knowledge bases.""" rag_module = get_rag_module() mock_app = create_mock_app() - mock_app.persistence_mgr.execute_async = AsyncMock( - return_value=Mock(all=Mock(return_value=[])) - ) + mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[]))) manager = rag_module.RAGManager(mock_app) result = await manager.get_all_knowledge_base_details() @@ -699,9 +682,7 @@ class TestRAGManagerGetAllDetails: # Mock DB result mock_kb_row = Mock() - mock_app.persistence_mgr.execute_async = AsyncMock( - return_value=Mock(all=Mock(return_value=[mock_kb_row])) - ) + mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[mock_kb_row]))) mock_app.persistence_mgr.serialize_model = Mock( return_value={'uuid': 'kb1', 'knowledge_engine_plugin_id': 'author/engine'} ) @@ -724,9 +705,7 @@ class TestRAGManagerGetDetails: """Test returns None when KB doesn't exist.""" rag_module = get_rag_module() mock_app = create_mock_app() - mock_app.persistence_mgr.execute_async = AsyncMock( - return_value=Mock(first=Mock(return_value=None)) - ) + mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None))) manager = rag_module.RAGManager(mock_app) result = await manager.get_knowledge_base_details('nonexistent') @@ -740,9 +719,7 @@ class TestRAGManagerGetDetails: mock_app = create_mock_app() mock_kb_row = Mock() - mock_app.persistence_mgr.execute_async = AsyncMock( - return_value=Mock(first=Mock(return_value=mock_kb_row)) - ) + mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=mock_kb_row))) mock_app.persistence_mgr.serialize_model = Mock( return_value={'uuid': 'kb1', 'knowledge_engine_plugin_id': 'author/engine'} ) @@ -791,4 +768,4 @@ class TestRAGManagerLoadKnowledgeBase: await manager.load_knowledge_base(kb_dict) - assert 'kb-uuid' in manager.knowledge_bases \ No newline at end of file + assert 'kb-uuid' in manager.knowledge_bases diff --git a/tests/unit_tests/rag/test_runtime_service.py b/tests/unit_tests/rag/test_runtime_service.py index b5c60ccba..650b3bf2f 100644 --- a/tests/unit_tests/rag/test_runtime_service.py +++ b/tests/unit_tests/rag/test_runtime_service.py @@ -121,10 +121,12 @@ class TestRAGRuntimeServiceVectorSearch: """Create mock app.""" mock_app = MagicMock() mock_app.vector_db_mgr = MagicMock() - mock_app.vector_db_mgr.search = AsyncMock(return_value=[ - {'id': 'id1', 'distance': 0.1, 'metadata': {'file_id': 'abc'}}, - {'id': 'id2', 'distance': 0.2, 'metadata': {'file_id': 'def'}}, - ]) + mock_app.vector_db_mgr.search = AsyncMock( + return_value=[ + {'id': 'id1', 'distance': 0.1, 'metadata': {'file_id': 'abc'}}, + {'id': 'id2', 'distance': 0.2, 'metadata': {'file_id': 'def'}}, + ] + ) return mock_app def _make_rag_import_mocks(self): @@ -301,10 +303,7 @@ class TestRAGRuntimeServiceVectorList: mock_app = MagicMock() mock_app.vector_db_mgr = MagicMock() mock_app.vector_db_mgr.list_by_filter = AsyncMock( - return_value=( - [{'id': 'id1', 'metadata': {'file_id': 'abc'}}], - 10 - ) + return_value=([{'id': 'id1', 'metadata': {'file_id': 'abc'}}], 10) ) return mock_app diff --git a/tests/unit_tests/storage/test_localstorage_path_traversal.py b/tests/unit_tests/storage/test_localstorage_path_traversal.py index 8c5ebf527..5e950eb32 100644 --- a/tests/unit_tests/storage/test_localstorage_path_traversal.py +++ b/tests/unit_tests/storage/test_localstorage_path_traversal.py @@ -21,8 +21,8 @@ from langbot.pkg.storage.providers.localstorage import LocalStorageProvider @pytest.fixture def storage_provider(tmp_path): """Create a LocalStorageProvider with a temporary storage path.""" - storage_path = str(tmp_path / "storage") - with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + storage_path = str(tmp_path / 'storage') + with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path): mock_app = Mock() provider = LocalStorageProvider(mock_app) yield provider, storage_path @@ -35,15 +35,15 @@ class TestPathTraversalPrevention: async def test_absolute_path_save_rejected(self, storage_provider, tmp_path): """Saving with an absolute path key must be blocked.""" provider, storage_path = storage_provider - target_file = str(tmp_path / "pwned.txt") + target_file = str(tmp_path / 'pwned.txt') - with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path): with pytest.raises((ValueError, PermissionError)): - await provider.save(target_file, b"malicious content") + await provider.save(target_file, b'malicious content') # The file must NOT exist outside the storage directory assert not os.path.exists(target_file), ( - f"Path traversal succeeded: file was written outside storage to {target_file}" + f'Path traversal succeeded: file was written outside storage to {target_file}' ) @pytest.mark.asyncio @@ -52,32 +52,28 @@ class TestPathTraversalPrevention: provider, storage_path = storage_provider # Create a file outside the storage directory - target_file = str(tmp_path / "secret.txt") - with open(target_file, "wb") as f: - f.write(b"secret data") + target_file = str(tmp_path / 'secret.txt') + with open(target_file, 'wb') as f: + f.write(b'secret data') - with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path): with pytest.raises((ValueError, PermissionError, FileNotFoundError)): data = await provider.load(target_file) - assert data != b"secret data", ( - "Path traversal succeeded: read file outside storage" - ) + assert data != b'secret data', 'Path traversal succeeded: read file outside storage' @pytest.mark.asyncio async def test_absolute_path_exists_rejected(self, storage_provider, tmp_path): """Exists check with an absolute path key must be blocked or return False.""" provider, storage_path = storage_provider - target_file = str(tmp_path / "check_me.txt") - with open(target_file, "wb") as f: - f.write(b"data") + target_file = str(tmp_path / 'check_me.txt') + with open(target_file, 'wb') as f: + f.write(b'data') - with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path): try: result = await provider.exists(target_file) - assert result is False, ( - "Path traversal succeeded: exists() returned True for file outside storage" - ) + assert result is False, 'Path traversal succeeded: exists() returned True for file outside storage' except (ValueError, PermissionError): pass # Expected @@ -86,28 +82,26 @@ class TestPathTraversalPrevention: """Deleting with an absolute path key must be blocked.""" provider, storage_path = storage_provider - target_file = str(tmp_path / "do_not_delete.txt") - with open(target_file, "wb") as f: - f.write(b"important data") + target_file = str(tmp_path / 'do_not_delete.txt') + with open(target_file, 'wb') as f: + f.write(b'important data') - with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path): with pytest.raises((ValueError, PermissionError, FileNotFoundError)): await provider.delete(target_file) - assert os.path.exists(target_file), ( - "Path traversal succeeded: file outside storage was deleted" - ) + assert os.path.exists(target_file), 'Path traversal succeeded: file outside storage was deleted' @pytest.mark.asyncio async def test_absolute_path_size_rejected(self, storage_provider, tmp_path): """Size check with an absolute path key must be blocked.""" provider, storage_path = storage_provider - target_file = str(tmp_path / "measure_me.txt") - with open(target_file, "wb") as f: - f.write(b"some data") + target_file = str(tmp_path / 'measure_me.txt') + with open(target_file, 'wb') as f: + f.write(b'some data') - with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path): with pytest.raises((ValueError, PermissionError, FileNotFoundError)): await provider.size(target_file) @@ -116,41 +110,39 @@ class TestPathTraversalPrevention: """Relative path traversal with '..' must be blocked.""" provider, storage_path = storage_provider - target_file = str(tmp_path / "above_storage.txt") - with open(target_file, "wb") as f: - f.write(b"above storage secret") + target_file = str(tmp_path / 'above_storage.txt') + with open(target_file, 'wb') as f: + f.write(b'above storage secret') - with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): - relative_key = os.path.join("..", "above_storage.txt") + with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path): + relative_key = os.path.join('..', 'above_storage.txt') with pytest.raises((ValueError, PermissionError, FileNotFoundError)): data = await provider.load(relative_key) - assert data != b"above storage secret" + assert data != b'above storage secret' @pytest.mark.asyncio async def test_delete_dir_recursive_traversal_rejected(self, storage_provider, tmp_path): """delete_dir_recursive with traversal path must be blocked.""" provider, storage_path = storage_provider - outside_dir = tmp_path / "outside_dir" + outside_dir = tmp_path / 'outside_dir' outside_dir.mkdir() - (outside_dir / "file.txt").write_text("important") + (outside_dir / 'file.txt').write_text('important') - with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path): with pytest.raises((ValueError, PermissionError)): await provider.delete_dir_recursive(str(outside_dir)) - assert outside_dir.exists(), ( - "Path traversal succeeded: directory outside storage was deleted" - ) + assert outside_dir.exists(), 'Path traversal succeeded: directory outside storage was deleted' @pytest.mark.asyncio async def test_legitimate_key_works(self, storage_provider): """Normal keys without traversal must still work.""" provider, storage_path = storage_provider - with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): - key = "test_image_abc123.png" - content = b"PNG image data" + with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path): + key = 'test_image_abc123.png' + content = b'PNG image data' await provider.save(key, content) assert await provider.exists(key) is True @@ -166,9 +158,9 @@ class TestPathTraversalPrevention: """Keys with legitimate subdirectories must still work.""" provider, storage_path = storage_provider - with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): - key = "bot_log_images/img_001.png" - content = b"PNG image data" + with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path): + key = 'bot_log_images/img_001.png' + content = b'PNG image data' await provider.save(key, content) assert await provider.exists(key) is True @@ -181,33 +173,33 @@ class TestPathTraversalPrevention: """delete_dir_recursive should handle non-existing directories gracefully.""" provider, storage_path = storage_provider - with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path): # Try to delete a non-existing directory - should not raise - await provider.delete_dir_recursive("nonexistent_dir") + await provider.delete_dir_recursive('nonexistent_dir') @pytest.mark.asyncio async def test_delete_dir_recursive_with_files(self, storage_provider): """delete_dir_recursive should delete directory with files inside.""" provider, storage_path = storage_provider - with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path): # Create a directory with files - key1 = "test_dir/file1.txt" - key2 = "test_dir/file2.txt" - await provider.save(key1, b"content1") - await provider.save(key2, b"content2") + key1 = 'test_dir/file1.txt' + key2 = 'test_dir/file2.txt' + await provider.save(key1, b'content1') + await provider.save(key2, b'content2') # Verify files exist assert await provider.exists(key1) assert await provider.exists(key2) # Delete directory recursively - await provider.delete_dir_recursive("test_dir") + await provider.delete_dir_recursive('test_dir') # Verify files no longer exist assert not await provider.exists(key1) assert not await provider.exists(key2) -if __name__ == "__main__": - pytest.main([__file__, "-v"]) +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/tests/unit_tests/storage/test_s3storage.py b/tests/unit_tests/storage/test_s3storage.py index 20bf6f00e..eb3d0f7e6 100644 --- a/tests/unit_tests/storage/test_s3storage.py +++ b/tests/unit_tests/storage/test_s3storage.py @@ -8,6 +8,7 @@ Tests cover: Uses moto library to mock AWS S3 service. """ + from __future__ import annotations import pytest @@ -44,8 +45,10 @@ def mock_app_with_s3_config(): def s3_mock(): """Set up moto S3 mock context.""" from moto import mock_aws + with mock_aws(): import boto3 + # Create bucket for tests that need pre-existing bucket s3 = boto3.client('s3', region_name='us-east-1') yield s3 @@ -325,4 +328,4 @@ class TestS3StorageProviderErrorHandling: await provider.initialize() with pytest.raises(Exception): - await provider.size('nonexistent.txt') \ No newline at end of file + await provider.size('nonexistent.txt') diff --git a/tests/unit_tests/storage/test_storage_manager.py b/tests/unit_tests/storage/test_storage_manager.py index c0b64cae4..d96f1cb04 100644 --- a/tests/unit_tests/storage/test_storage_manager.py +++ b/tests/unit_tests/storage/test_storage_manager.py @@ -31,7 +31,7 @@ class TestStorageMgr: storage_mgr = StorageMgr(mock_app) - with patch.object(LocalStorageProvider, "initialize", new_callable=AsyncMock): + with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock): await storage_mgr.initialize() assert isinstance(storage_mgr.storage_provider, LocalStorageProvider) mock_app.logger.info.assert_called() @@ -41,12 +41,12 @@ class TestStorageMgr: """Should use local storage when explicitly configured.""" mock_app = Mock() mock_app.instance_config = Mock() - mock_app.instance_config.data = {"storage": {"use": "local"}} + mock_app.instance_config.data = {'storage': {'use': 'local'}} mock_app.logger = Mock() storage_mgr = StorageMgr(mock_app) - with patch.object(LocalStorageProvider, "initialize", new_callable=AsyncMock): + with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock): await storage_mgr.initialize() assert isinstance(storage_mgr.storage_provider, LocalStorageProvider) @@ -55,14 +55,12 @@ class TestStorageMgr: """Should use S3 storage when configured.""" mock_app = Mock() mock_app.instance_config = Mock() - mock_app.instance_config.data = { - "storage": {"use": "s3", "s3": {"endpoint_url": "https://s3.amazonaws.com"}} - } + mock_app.instance_config.data = {'storage': {'use': 's3', 's3': {'endpoint_url': 'https://s3.amazonaws.com'}}} mock_app.logger = Mock() storage_mgr = StorageMgr(mock_app) - with patch.object(S3StorageProvider, "initialize", new_callable=AsyncMock): + with patch.object(S3StorageProvider, 'initialize', new_callable=AsyncMock): await storage_mgr.initialize() assert isinstance(storage_mgr.storage_provider, S3StorageProvider) @@ -71,12 +69,12 @@ class TestStorageMgr: """Should default to local storage for invalid storage type.""" mock_app = Mock() mock_app.instance_config = Mock() - mock_app.instance_config.data = {"storage": {"use": "invalid_type"}} + mock_app.instance_config.data = {'storage': {'use': 'invalid_type'}} mock_app.logger = Mock() storage_mgr = StorageMgr(mock_app) - with patch.object(LocalStorageProvider, "initialize", new_callable=AsyncMock): + with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock): await storage_mgr.initialize() assert isinstance(storage_mgr.storage_provider, LocalStorageProvider) @@ -90,9 +88,7 @@ class TestStorageMgr: storage_mgr = StorageMgr(mock_app) - with patch.object( - LocalStorageProvider, "initialize", new_callable=AsyncMock - ) as mock_init: + with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock) as mock_init: await storage_mgr.initialize() mock_init.assert_called_once() @@ -105,8 +101,8 @@ class TestStorageProviderBase: mock_app = Mock() # Use LocalStorageProvider as concrete implementation - with patch("os.path.exists", return_value=True): - with patch("os.makedirs"): + with patch('os.path.exists', return_value=True): + with patch('os.makedirs'): provider = LocalStorageProvider(mock_app) assert provider.ap == mock_app @@ -115,12 +111,12 @@ class TestStorageProviderBase: """Provider base initialize should be callable and do nothing.""" mock_app = Mock() - with patch("os.path.exists", return_value=True): - with patch("os.makedirs"): + with patch('os.path.exists', return_value=True): + with patch('os.makedirs'): provider = LocalStorageProvider(mock_app) # Initialize should not raise await provider.initialize() -if __name__ == "__main__": - pytest.main([__file__, "-v"]) \ No newline at end of file +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/tests/unit_tests/survey/test_survey_manager.py b/tests/unit_tests/survey/test_survey_manager.py index ae6017e16..5bf219ad3 100644 --- a/tests/unit_tests/survey/test_survey_manager.py +++ b/tests/unit_tests/survey/test_survey_manager.py @@ -7,6 +7,7 @@ Tests cover: - Survey response submission - Survey dismissal """ + from __future__ import annotations import pytest @@ -127,9 +128,7 @@ class TestLoadTriggeredEvents: """Test that empty set is used when no events stored.""" survey_module = get_survey_module() mock_app = create_mock_app() - mock_app.persistence_mgr.execute_async = AsyncMock( - return_value=Mock(first=Mock(return_value=None)) - ) + mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None))) manager = survey_module.SurveyManager(mock_app) await manager._load_triggered_events() @@ -219,9 +218,7 @@ class TestTriggerEvent: """Test that new event is added and saved.""" survey_module = get_survey_module() mock_app = create_mock_app() - mock_app.persistence_mgr.execute_async = AsyncMock( - return_value=Mock(first=Mock(return_value=None)) - ) + mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None))) manager = survey_module.SurveyManager(mock_app) manager._space_url = 'https://space.example.com' @@ -231,6 +228,104 @@ class TestTriggerEvent: assert 'new_event' in manager._triggered_events +class TestRecordBotResponseSuccess: + """Tests for the bot_response_success_100 milestone counter.""" + + def _make_manager(self, survey_module, mock_app): + manager = survey_module.SurveyManager(mock_app) + manager._space_url = 'https://space.example.com' + # No existing metadata rows: select returns no row + mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None))) + return manager + + @pytest.mark.asyncio + async def test_increments_and_persists_count(self): + survey_module = get_survey_module() + mock_app = create_mock_app() + manager = self._make_manager(survey_module, mock_app) + + await manager.record_bot_response_success() + + assert manager._bot_response_count == 1 + # select + insert for the count key + assert mock_app.persistence_mgr.execute_async.call_count >= 2 + + @pytest.mark.asyncio + async def test_fires_milestone_event_at_threshold(self): + survey_module = get_survey_module() + mock_app = create_mock_app() + manager = self._make_manager(survey_module, mock_app) + manager._bot_response_count = survey_module.BOT_RESPONSE_MILESTONE - 1 + + await manager.record_bot_response_success() + + assert manager._bot_response_count == survey_module.BOT_RESPONSE_MILESTONE + assert survey_module.BOT_RESPONSE_MILESTONE_EVENT in manager._triggered_events + + @pytest.mark.asyncio + async def test_does_not_fire_below_threshold(self): + survey_module = get_survey_module() + mock_app = create_mock_app() + manager = self._make_manager(survey_module, mock_app) + manager._bot_response_count = 5 + + await manager.record_bot_response_success() + + assert survey_module.BOT_RESPONSE_MILESTONE_EVENT not in manager._triggered_events + + @pytest.mark.asyncio + async def test_stops_counting_after_milestone_triggered(self): + survey_module = get_survey_module() + mock_app = create_mock_app() + manager = self._make_manager(survey_module, mock_app) + manager._triggered_events.add(survey_module.BOT_RESPONSE_MILESTONE_EVENT) + manager._bot_response_count = survey_module.BOT_RESPONSE_MILESTONE + + await manager.record_bot_response_success() + + # No persistence write, count unchanged + mock_app.persistence_mgr.execute_async.assert_not_called() + assert manager._bot_response_count == survey_module.BOT_RESPONSE_MILESTONE + + @pytest.mark.asyncio + async def test_skips_when_space_not_configured(self): + survey_module = get_survey_module() + mock_app = create_mock_app() + manager = self._make_manager(survey_module, mock_app) + manager._space_url = '' + + await manager.record_bot_response_success() + + assert manager._bot_response_count == 0 + mock_app.persistence_mgr.execute_async.assert_not_called() + + @pytest.mark.asyncio + async def test_count_loaded_on_initialize(self): + survey_module = get_survey_module() + mock_app = create_mock_app() + + count_row = Mock() + count_row.value = '42' + + def execute_side_effect(stmt): + result = Mock() + # Both _load_triggered_events and _load_bot_response_count select + # from Metadata; return the count row only for the count key. + stmt_str = str(stmt.compile(compile_kwargs={'literal_binds': True})) + if survey_module.BOT_RESPONSE_COUNT_KEY in stmt_str: + result.first.return_value = (count_row,) + else: + result.first.return_value = None + return result + + mock_app.persistence_mgr.execute_async = AsyncMock(side_effect=execute_side_effect) + + manager = survey_module.SurveyManager(mock_app) + await manager.initialize() + + assert manager._bot_response_count == 42 + + class TestPendingSurvey: """Tests for get_pending_survey and clear_pending_survey.""" @@ -296,14 +391,19 @@ class TestSubmitResponse: # Mock successful HTTP response import httpx + mock_response = Mock() mock_response.status_code = 200 with pytest.MonkeyPatch().context() as m: - m.setattr(httpx, 'AsyncClient', lambda **kwargs: MagicMock( - __aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))), - __aexit__=AsyncMock(return_value=None) - )) + m.setattr( + httpx, + 'AsyncClient', + lambda **kwargs: MagicMock( + __aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))), + __aexit__=AsyncMock(return_value=None), + ), + ) result = await manager.submit_response('survey123', {'q1': 'answer1'}) assert result is True @@ -338,15 +438,20 @@ class TestDismissSurvey: # Mock successful HTTP response import httpx + mock_response = Mock() mock_response.status_code = 200 with pytest.MonkeyPatch().context() as m: - m.setattr(httpx, 'AsyncClient', lambda **kwargs: MagicMock( - __aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))), - __aexit__=AsyncMock(return_value=None) - )) + m.setattr( + httpx, + 'AsyncClient', + lambda **kwargs: MagicMock( + __aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))), + __aexit__=AsyncMock(return_value=None), + ), + ) result = await manager.dismiss_survey('survey123') assert result is True - assert manager._pending_survey is None \ No newline at end of file + assert manager._pending_survey is None diff --git a/tests/unit_tests/telemetry/test_features.py b/tests/unit_tests/telemetry/test_features.py new file mode 100644 index 000000000..6e19d0828 --- /dev/null +++ b/tests/unit_tests/telemetry/test_features.py @@ -0,0 +1,92 @@ +"""Unit tests for telemetry feature counters (pkg/telemetry/features.py).""" + +from __future__ import annotations + +from importlib import import_module + + +def get_features_module(): + return import_module('langbot.pkg.telemetry.features') + + +class FakeQuery: + def __init__(self): + self.variables = {} + + +class TestIncrement: + def test_increment_nested_counter(self): + features = get_features_module() + q = FakeQuery() + features.increment(q, 'tool_calls', 'native') + features.increment(q, 'tool_calls', 'native') + features.increment(q, 'tool_calls', 'mcp') + assert q.variables[features.FEATURES_KEY]['tool_calls'] == {'native': 2, 'mcp': 1} + + def test_increment_flat_counter(self): + features = get_features_module() + q = FakeQuery() + features.increment(q, 'something') + features.increment(q, 'something', amount=2) + assert q.variables[features.FEATURES_KEY]['something'] == 3 + + def test_increment_never_raises_on_broken_query(self): + features = get_features_module() + + class Broken: + @property + def variables(self): + raise RuntimeError('boom') + + # Must not raise + features.increment(Broken(), 'tool_calls', 'native') + + def test_set_value(self): + features = get_features_module() + q = FakeQuery() + features.set_value(q, 'tool_call_rounds', 5) + assert q.variables[features.FEATURES_KEY]['tool_call_rounds'] == 5 + + +class TestCollectFeatures: + def test_collect_empty(self): + features = get_features_module() + q = FakeQuery() + assert features.collect_features(q) == {} + + def test_collect_combines_counters_and_snapshots(self): + features = get_features_module() + q = FakeQuery() + features.increment(q, 'sandbox', 'execs') + features.set_value(q, 'kb', {'kb_count': 2, 'engine_plugins': ['builtin'], 'retrieved_entries': 7}) + q.variables['_activated_skills'] = {'pdf-tools': {}, 'a-skill': {}} + q.variables['_pipeline_bound_mcp_servers'] = ['srv1', 'srv2'] + + result = features.collect_features(q) + assert result['sandbox'] == {'execs': 1} + assert result['kb']['kb_count'] == 2 + assert result['activated_skills'] == ['a-skill', 'pdf-tools'] # sorted + assert result['mcp_servers'] == ['srv1', 'srv2'] + + def test_collect_omits_mcp_when_all_enabled(self): + """None means 'all enabled' and is not reported.""" + features = get_features_module() + q = FakeQuery() + q.variables['_pipeline_bound_mcp_servers'] = None + assert 'mcp_servers' not in features.collect_features(q) + + def test_collect_drops_non_json_serializable(self): + features = get_features_module() + q = FakeQuery() + features.set_value(q, 'good', 1) + features.set_value(q, 'bad', object()) + result = features.collect_features(q) + assert result == {'good': 1} + + def test_collect_is_json_serializable(self): + import json + + features = get_features_module() + q = FakeQuery() + features.increment(q, 'tool_calls', 'skill') + json.dumps(features.collect_features(q)) diff --git a/tests/unit_tests/telemetry/test_heartbeat.py b/tests/unit_tests/telemetry/test_heartbeat.py new file mode 100644 index 000000000..18d61f2d8 --- /dev/null +++ b/tests/unit_tests/telemetry/test_heartbeat.py @@ -0,0 +1,105 @@ +"""Unit tests for telemetry heartbeat payload (pkg/telemetry/heartbeat.py).""" + +from __future__ import annotations + +import json + +import pytest +from unittest.mock import AsyncMock, Mock +from importlib import import_module + + +def get_heartbeat_module(): + return import_module('langbot.pkg.telemetry.heartbeat') + + +def make_app(): + ap = Mock() + ap.instance_config = Mock() + ap.instance_config.data = { + 'database': {'use': 'postgresql'}, + 'vdb': {'use': 'chroma'}, + 'box': {'enabled': True, 'backend': 'nsjail'}, + } + + # persistence counts + result = Mock() + result.scalar.return_value = 3 + ap.persistence_mgr = Mock() + ap.persistence_mgr.execute_async = AsyncMock(return_value=result) + + # box service + ap.box_service = Mock() + ap.box_service.enabled = True + ap.box_service.available = False + ap.box_service.shares_filesystem_with_box = False + + # platform manager with one enabled bot + bot = Mock() + bot.enable = True + bot.adapter = Mock() + bot.adapter.__class__.__name__ = 'TelegramAdapter' + ap.platform_mgr = Mock() + ap.platform_mgr.bots = [bot] + + # plugin connector + ap.plugin_connector = Mock() + ap.plugin_connector.list_plugins = AsyncMock(return_value=[{}, {}]) + + # skills + ap.skill_mgr = Mock() + ap.skill_mgr.skills = {'a': {}, 'b': {}, 'c': {}} + + return ap + + +class TestBuildHeartbeatPayload: + @pytest.mark.asyncio + async def test_payload_shape(self): + heartbeat = get_heartbeat_module() + ap = make_app() + payload = await heartbeat.build_heartbeat_payload(ap) + + assert payload['event_type'] == 'instance_heartbeat' + assert payload['query_id'] == '' + assert 'instance_create_ts' in payload + assert 'timestamp' in payload + f = payload['features'] + assert f['database'] == 'postgresql' + assert f['vdb'] == 'chroma' + assert f['box'] == { + 'enabled': True, + 'available': False, + 'backend': 'nsjail', + 'shares_fs': False, + } + assert f['adapters'] == ['TelegramAdapter'] + assert f['bot_count'] == 1 + assert f['plugin_count'] == 2 + assert f['skill_count'] == 3 + assert f['pipeline_count'] == 3 + assert f['mcp_server_count'] == 3 + assert f['knowledge_base_count'] == 3 + + @pytest.mark.asyncio + async def test_payload_is_json_serializable(self): + heartbeat = get_heartbeat_module() + payload = await heartbeat.build_heartbeat_payload(make_app()) + json.dumps(payload) + + @pytest.mark.asyncio + async def test_count_failure_yields_minus_one(self): + heartbeat = get_heartbeat_module() + ap = make_app() + ap.persistence_mgr.execute_async = AsyncMock(side_effect=RuntimeError('db down')) + payload = await heartbeat.build_heartbeat_payload(ap) + assert payload['features']['pipeline_count'] == -1 + + @pytest.mark.asyncio + async def test_no_user_content_fields(self): + """The heartbeat must never carry message content / credentials keys.""" + heartbeat = get_heartbeat_module() + payload = await heartbeat.build_heartbeat_payload(make_app()) + flat = json.dumps(payload).lower() + for forbidden in ('api_key', 'password', 'token', 'message_content'): + assert forbidden not in flat diff --git a/tests/unit_tests/telemetry/test_telemetry.py b/tests/unit_tests/telemetry/test_telemetry.py index 2ceb1f09c..b15a989ee 100644 --- a/tests/unit_tests/telemetry/test_telemetry.py +++ b/tests/unit_tests/telemetry/test_telemetry.py @@ -8,6 +8,7 @@ Tests cover: - HTTP request success/failure scenarios - Source code bug: send_tasks should be instance variable """ + from __future__ import annotations import pytest @@ -38,6 +39,7 @@ class TestTelemetryManagerInit: manager = telemetry.TelemetryManager(mock_app) assert manager.telemetry_config == {} + class TestTelemetryManagerInitialize: """Tests for initialize() method.""" @@ -218,7 +220,7 @@ class TestPayloadSanitization: # All null string fields should be empty strings for field in ['adapter', 'runner', 'runner_category', 'model_name', 'version', 'edition', 'error', 'timestamp']: - assert result[field] == '', f"Field {field} should be empty string, got {result[field]}" + assert result[field] == '', f'Field {field} should be empty string, got {result[field]}' @pytest.mark.asyncio async def test_sanitize_string_fields_preserve_values(self): @@ -418,9 +420,7 @@ class TestHTTPScenarios: manager.telemetry_config = {'url': 'https://example.com'} mock_response = Mock( - status_code=200, - text='{"code": 0, "msg": "success"}', - json=Mock(return_value={'code': 0, 'msg': 'success'}) + status_code=200, text='{"code": 0, "msg": "success"}', json=Mock(return_value={'code': 0, 'msg': 'success'}) ) mock_client = Mock() @@ -448,9 +448,7 @@ class TestHTTPScenarios: manager.telemetry_config = {'url': 'https://example.com'} mock_response = Mock( - status_code=500, - text='Internal Server Error', - json=Mock(return_value={'code': 500, 'msg': 'error'}) + status_code=500, text='Internal Server Error', json=Mock(return_value={'code': 500, 'msg': 'error'}) ) mock_client = Mock() @@ -478,7 +476,7 @@ class TestHTTPScenarios: mock_response = Mock( status_code=200, text='{"code": 400, "msg": "Bad Request"}', - json=Mock(return_value={'code': 400, 'msg': 'Bad Request'}) + json=Mock(return_value={'code': 400, 'msg': 'Bad Request'}), ) mock_client = Mock() @@ -493,7 +491,7 @@ class TestHTTPScenarios: assert mock_app.logger.warning.call_count >= 1 # Check that one of the calls contains application error info all_warnings = [call[0][0] for call in mock_app.logger.warning.call_args_list] - assert any('400' in w for w in all_warnings), f"No warning contained error code 400: {all_warnings}" + assert any('400' in w for w in all_warnings), f'No warning contained error code 400: {all_warnings}' @pytest.mark.asyncio async def test_send_timeout_logs_warning(self): diff --git a/tests/unit_tests/test_paths.py b/tests/unit_tests/test_paths.py new file mode 100644 index 000000000..a631ff7f7 --- /dev/null +++ b/tests/unit_tests/test_paths.py @@ -0,0 +1,23 @@ +from pathlib import Path + +from langbot.pkg.utils import paths + + +def test_get_data_root_uses_source_root_in_repo_checkout(): + data_root = Path(paths.get_data_root()) + repo_root = Path(__file__).resolve().parents[2] + + assert data_root == repo_root / 'data' + + +def test_get_data_path_joins_under_data_root(): + data_path = Path(paths.get_data_path('skills', 'demo-skill')) + repo_root = Path(__file__).resolve().parents[2] + + assert data_path == repo_root / 'data' / 'skills' / 'demo-skill' + + +def test_get_data_root_honors_env_override(monkeypatch, tmp_path): + monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'custom-data')) + + assert Path(paths.get_data_root()) == (tmp_path / 'custom-data').resolve() diff --git a/tests/unit_tests/test_preproc.py b/tests/unit_tests/test_preproc.py new file mode 100644 index 000000000..8f05277cb --- /dev/null +++ b/tests/unit_tests/test_preproc.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import importlib +import sys +import types +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot_plugin.api.entities.builtin.pipeline.query import Query +from langbot_plugin.api.entities.builtin.platform.entities import Friend +from langbot_plugin.api.entities.builtin.platform.events import FriendMessage +from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain +from langbot_plugin.api.entities.builtin.provider.message import Message +from langbot_plugin.api.entities.builtin.provider.prompt import Prompt +from langbot_plugin.api.entities.builtin.provider.session import Conversation, LauncherTypes, Session + + +def _make_query() -> Query: + message_chain = MessageChain([Plain(text='create a skill')]) + return Query( + query_id=1, + launcher_type=LauncherTypes.PERSON, + launcher_id='launcher-1', + sender_id='sender-1', + message_event=FriendMessage( + message_chain=message_chain, + time=0, + sender=Friend(id='sender-1', nickname='Tester', remark='Tester'), + ), + message_chain=message_chain, + bot_uuid='bot-1', + pipeline_uuid='pipe-1', + pipeline_config={ + 'ai': { + 'runner': {'runner': 'local-agent'}, + 'local-agent': { + 'model': {'primary': 'model-1', 'fallbacks': []}, + 'prompt': 'default', + 'knowledge-bases': [], + }, + }, + 'trigger': {'misc': {}}, + }, + variables={}, + ) + + +def _make_conversation() -> Conversation: + return Conversation( + prompt=Prompt(name='default', messages=[Message(role='system', content='system prompt')]), + messages=[], + pipeline_uuid='pipe-1', + bot_uuid='bot-1', + uuid='conv-1', + ) + + +def _make_app(*, skill_service) -> SimpleNamespace: + session = Session(launcher_type=LauncherTypes.PERSON, launcher_id='launcher-1', sender_id='sender-1') + conversation = _make_conversation() + model = SimpleNamespace(model_entity=SimpleNamespace(uuid='model-1', abilities={'func_call'})) + tool_mgr = SimpleNamespace(get_all_tools=AsyncMock(return_value=[])) + + return SimpleNamespace( + sess_mgr=SimpleNamespace( + get_session=AsyncMock(return_value=session), + get_conversation=AsyncMock(return_value=conversation), + ), + model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)), + tool_mgr=tool_mgr, + plugin_connector=SimpleNamespace( + emit_event=AsyncMock( + return_value=SimpleNamespace( + event=SimpleNamespace( + default_prompt=conversation.prompt.messages.copy(), + prompt=conversation.messages.copy(), + ) + ) + ) + ), + pipeline_service=SimpleNamespace( + get_pipeline=AsyncMock(return_value={'extensions_preferences': {'enable_all_skills': True}}) + ), + skill_mgr=SimpleNamespace( + build_skill_aware_prompt_addition=Mock(return_value=''), + skills={}, + ), + skill_service=skill_service, + logger=Mock(), + ) + + +def _import_preproc_modules(): + fake_app_module = types.ModuleType('langbot.pkg.core.app') + fake_app_module.Application = object + sys.modules['langbot.pkg.core.app'] = fake_app_module + + for module_name in ( + 'langbot.pkg.pipeline.preproc.preproc', + 'langbot.pkg.pipeline.stage', + ): + sys.modules.pop(module_name, None) + + preproc_module = importlib.import_module('langbot.pkg.pipeline.preproc.preproc') + entities_module = importlib.import_module('langbot.pkg.pipeline.entities') + return preproc_module, entities_module + + +@pytest.mark.asyncio +async def test_preproc_enables_skill_authoring_tools_when_skill_service_available(): + preproc_module, entities_module = _import_preproc_modules() + + app = _make_app(skill_service=SimpleNamespace()) + stage = preproc_module.PreProcessor(app) + + result = await stage.process(_make_query(), 'PreProcessor') + + assert result.result_type == entities_module.ResultType.CONTINUE + app.tool_mgr.get_all_tools.assert_awaited_once_with( + None, + None, + include_skill_authoring=True, + include_mcp_resource_tools=True, + ) + + +@pytest.mark.asyncio +async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing(): + preproc_module, entities_module = _import_preproc_modules() + + app = _make_app(skill_service=None) + stage = preproc_module.PreProcessor(app) + + result = await stage.process(_make_query(), 'PreProcessor') + + assert result.result_type == entities_module.ResultType.CONTINUE + app.tool_mgr.get_all_tools.assert_awaited_once_with( + None, + None, + include_skill_authoring=False, + include_mcp_resource_tools=True, + ) + + +@pytest.mark.asyncio +async def test_preproc_disables_mcp_resource_tools_when_agent_reading_is_disabled(): + preproc_module, entities_module = _import_preproc_modules() + + app = _make_app(skill_service=SimpleNamespace()) + stage = preproc_module.PreProcessor(app) + query = _make_query() + query.variables['_pipeline_mcp_resource_agent_read_enabled'] = False + + result = await stage.process(query, 'PreProcessor') + + assert result.result_type == entities_module.ResultType.CONTINUE + app.tool_mgr.get_all_tools.assert_awaited_once_with( + None, + None, + include_skill_authoring=True, + include_mcp_resource_tools=False, + ) + + +@pytest.mark.asyncio +async def test_preproc_injects_skill_index_into_system_prompt(): + """The Tool Call activation pattern still needs the LLM to know which + skills exist. PreProcessor must append the SkillManager's index + addendum to the first system message.""" + preproc_module, entities_module = _import_preproc_modules() + + app = _make_app(skill_service=SimpleNamespace()) + addendum = '\n\nAvailable Skills:\n- demo (demo): Demo skill.\n\nCall activate ...' + app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value=addendum) + + query = _make_query() + result = await stage_process_capture(preproc_module, app, query) + + assert result.result_type == entities_module.ResultType.CONTINUE + app.skill_mgr.build_skill_aware_prompt_addition.assert_called_once_with(bound_skills=None) + head = query.prompt.messages[0] + assert head.role == 'system' + assert head.content.endswith(addendum) + + +@pytest.mark.asyncio +async def test_preproc_respects_pipeline_bound_skills_subset(): + """When ``enable_all_skills`` is false the bound list is passed through + so the addendum only mentions skills allowed for this pipeline.""" + preproc_module, entities_module = _import_preproc_modules() + + app = _make_app(skill_service=SimpleNamespace()) + app.pipeline_service.get_pipeline = AsyncMock( + return_value={ + 'extensions_preferences': { + 'enable_all_skills': False, + 'skills': ['only-this'], + } + } + ) + app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='') + + query = _make_query() + result = await stage_process_capture(preproc_module, app, query) + + assert result.result_type == entities_module.ResultType.CONTINUE + app.skill_mgr.build_skill_aware_prompt_addition.assert_called_once_with(bound_skills=['only-this']) + assert query.variables.get('_pipeline_bound_skills') == ['only-this'] + + +@pytest.mark.asyncio +async def test_preproc_skips_injection_when_addendum_is_empty(): + """No visible skills → system prompt is left untouched (no + ``Available Skills`` block appended).""" + preproc_module, entities_module = _import_preproc_modules() + + app = _make_app(skill_service=SimpleNamespace()) + app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='') + + query = _make_query() + result = await stage_process_capture(preproc_module, app, query) + + assert result.result_type == entities_module.ResultType.CONTINUE + if query.prompt and query.prompt.messages: + assert 'Available Skills' not in (query.prompt.messages[0].content or '') + + +async def stage_process_capture(preproc_module, app, query): + """Run PreProcessor.process and return the result while keeping ``query`` + accessible to the assertions (process mutates query in place).""" + stage = preproc_module.PreProcessor(app) + return await stage.process(query, 'PreProcessor') diff --git a/tests/unit_tests/test_skill_service.py b/tests/unit_tests/test_skill_service.py new file mode 100644 index 000000000..6fd7d64f2 --- /dev/null +++ b/tests/unit_tests/test_skill_service.py @@ -0,0 +1,89 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from langbot.pkg.api.http.service.skill import SkillService + + +class TestRequireBoxForWrite: + """Box is the only source of truth for skills — there is no local + filesystem fallback. Every write and (most) read methods refuse cleanly + when the Box runtime is disabled, unreachable, or simply not installed.""" + + def _ap_with_disabled_box(self): + return SimpleNamespace( + skill_mgr=SimpleNamespace(reload_skills=AsyncMock()), + box_service=SimpleNamespace( + available=False, + enabled=False, + _connector_error='Box runtime is disabled in config (box.enabled = false)', + ), + ) + + def _ap_with_failed_box(self): + return SimpleNamespace( + skill_mgr=SimpleNamespace(reload_skills=AsyncMock()), + box_service=SimpleNamespace( + available=False, + enabled=True, + _connector_error='docker daemon not running', + ), + ) + + @pytest.mark.asyncio + async def test_create_skill_refused_when_box_disabled(self): + service = SkillService(self._ap_with_disabled_box()) + with pytest.raises(ValueError, match='disabled in config'): + await service.create_skill({'name': 'x'}) + + @pytest.mark.asyncio + async def test_create_skill_refused_when_box_failed(self): + service = SkillService(self._ap_with_failed_box()) + with pytest.raises(ValueError, match='docker daemon not running'): + await service.create_skill({'name': 'x'}) + + @pytest.mark.asyncio + async def test_update_skill_refused_when_box_disabled(self): + service = SkillService(self._ap_with_disabled_box()) + with pytest.raises(ValueError, match='Editing a skill requires the Box runtime'): + await service.update_skill('x', {}) + + @pytest.mark.asyncio + async def test_write_skill_file_refused_when_box_disabled(self): + service = SkillService(self._ap_with_disabled_box()) + with pytest.raises(ValueError, match='Editing skill files requires the Box runtime'): + await service.write_skill_file('x', 'a.txt', 'hi') + + @pytest.mark.asyncio + async def test_install_from_github_refused_when_box_disabled(self): + service = SkillService(self._ap_with_disabled_box()) + with pytest.raises(ValueError, match='Installing a skill from GitHub'): + await service.install_from_github({'owner': 'o', 'repo': 'r', 'asset_url': 'https://example/x.zip'}) + + @pytest.mark.asyncio + async def test_install_from_zip_upload_refused_when_box_disabled(self): + service = SkillService(self._ap_with_disabled_box()) + with pytest.raises(ValueError, match='Installing a skill from upload'): + await service.install_from_zip_upload(file_bytes=b'', filename='x.zip') + + @pytest.mark.asyncio + async def test_create_skill_refused_when_box_service_missing_entirely(self): + """No ap.box_service attribute at all (truly minimal setup): + Box is the only source of truth, so creation must still refuse.""" + service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock()))) + with pytest.raises(ValueError, match='not initialised'): + await service.create_skill({'name': 'x'}) + + @pytest.mark.asyncio + async def test_list_skills_returns_empty_when_box_unavailable(self): + """list_skills should render an empty surface (not crash) so the + skills page can show a banner instead of a broken state.""" + service = SkillService(self._ap_with_disabled_box()) + assert await service.list_skills() == [] + + @pytest.mark.asyncio + async def test_read_skill_file_refused_when_box_unavailable(self): + service = SkillService(self._ap_with_disabled_box()) + with pytest.raises(ValueError, match='Reading a skill file'): + await service.read_skill_file('x', 'a.txt') diff --git a/tests/unit_tests/utils/test_funcschema.py b/tests/unit_tests/utils/test_funcschema.py index c2b3bffe0..2d9e2d575 100644 --- a/tests/unit_tests/utils/test_funcschema.py +++ b/tests/unit_tests/utils/test_funcschema.py @@ -9,6 +9,7 @@ Tests cover: Note: Do NOT use 'from __future__ import annotations' because funcschema.py expects actual type objects, not string annotations. """ + import pytest from importlib import import_module diff --git a/tests/unit_tests/utils/test_image.py b/tests/unit_tests/utils/test_image.py index 291ba8c07..4a42717ba 100644 --- a/tests/unit_tests/utils/test_image.py +++ b/tests/unit_tests/utils/test_image.py @@ -20,55 +20,53 @@ class TestGetQQImageDownloadableUrl: def test_basic_url(self): """Parse basic image URL.""" - url = "http://example.com/image.jpg" + url = 'http://example.com/image.jpg' result_url, query = get_qq_image_downloadable_url(url) - assert result_url == "http://example.com/image.jpg" + assert result_url == 'http://example.com/image.jpg' assert query == {} def test_url_with_query_params(self): """Parse URL with query parameters.""" - url = "http://example.com/image.jpg?param1=value1¶m2=value2" + url = 'http://example.com/image.jpg?param1=value1¶m2=value2' result_url, query = get_qq_image_downloadable_url(url) - assert result_url == "http://example.com/image.jpg" - assert query == {"param1": ["value1"], "param2": ["value2"]} + assert result_url == 'http://example.com/image.jpg' + assert query == {'param1': ['value1'], 'param2': ['value2']} def test_url_with_port(self): """Parse URL with port number.""" - url = "http://example.com:8080/image.jpg" + url = 'http://example.com:8080/image.jpg' result_url, query = get_qq_image_downloadable_url(url) - assert result_url == "http://example.com:8080/image.jpg" + assert result_url == 'http://example.com:8080/image.jpg' def test_url_with_path(self): """Parse URL with complex path.""" - url = "http://example.com/path/to/image.jpg" + url = 'http://example.com/path/to/image.jpg' result_url, query = get_qq_image_downloadable_url(url) - assert result_url == "http://example.com/path/to/image.jpg" + assert result_url == 'http://example.com/path/to/image.jpg' def test_url_with_fragment(self): """Parse URL with fragment (fragment is not part of query).""" - url = "http://example.com/image.jpg#fragment" + url = 'http://example.com/image.jpg#fragment' result_url, query = get_qq_image_downloadable_url(url) # Fragment is not included in query string parsing - assert "http://example.com/image.jpg" in result_url + assert 'http://example.com/image.jpg' in result_url def test_https_url(self): """Parse HTTPS URL and preserve its scheme.""" - url = "https://example.com/image.jpg" + url = 'https://example.com/image.jpg' result_url, query = get_qq_image_downloadable_url(url) - assert result_url == "https://example.com/image.jpg" + assert result_url == 'https://example.com/image.jpg' assert query == {} def test_preserves_qq_https_scheme_and_query(self): """QQ image URLs keep HTTPS and query parameters.""" - result_url, query = get_qq_image_downloadable_url( - 'https://gchat.qpic.cn/gchatpic_new/abc/0?term=2&is_origin=1' - ) + result_url, query = get_qq_image_downloadable_url('https://gchat.qpic.cn/gchatpic_new/abc/0?term=2&is_origin=1') assert result_url == 'https://gchat.qpic.cn/gchatpic_new/abc/0' assert query == {'term': ['2'], 'is_origin': ['1']} @@ -88,50 +86,50 @@ class TestExtractB64AndFormat: async def test_jpeg_data_uri(self): """Extract base64 and format from JPEG data URI.""" # Create a simple base64 string - original_data = b"test image data" + original_data = b'test image data' b64_data = base64.b64encode(original_data).decode() - data_uri = f"data:image/jpeg;base64,{b64_data}" + data_uri = f'data:image/jpeg;base64,{b64_data}' result_b64, result_format = await extract_b64_and_format(data_uri) assert result_b64 == b64_data - assert result_format == "jpeg" + assert result_format == 'jpeg' @pytest.mark.asyncio async def test_png_data_uri(self): """Extract base64 and format from PNG data URI.""" - original_data = b"test png data" + original_data = b'test png data' b64_data = base64.b64encode(original_data).decode() - data_uri = f"data:image/png;base64,{b64_data}" + data_uri = f'data:image/png;base64,{b64_data}' result_b64, result_format = await extract_b64_and_format(data_uri) assert result_b64 == b64_data - assert result_format == "png" + assert result_format == 'png' @pytest.mark.asyncio async def test_gif_data_uri(self): """Extract base64 and format from GIF data URI.""" - original_data = b"test gif data" + original_data = b'test gif data' b64_data = base64.b64encode(original_data).decode() - data_uri = f"data:image/gif;base64,{b64_data}" + data_uri = f'data:image/gif;base64,{b64_data}' result_b64, result_format = await extract_b64_and_format(data_uri) assert result_b64 == b64_data - assert result_format == "gif" + assert result_format == 'gif' @pytest.mark.asyncio async def test_webp_data_uri(self): """Extract base64 and format from WebP data URI.""" - original_data = b"test webp data" + original_data = b'test webp data' b64_data = base64.b64encode(original_data).decode() - data_uri = f"data:image/webp;base64,{b64_data}" + data_uri = f'data:image/webp;base64,{b64_data}' result_b64, result_format = await extract_b64_and_format(data_uri) assert result_b64 == b64_data - assert result_format == "webp" + assert result_format == 'webp' @pytest.mark.asyncio async def test_complex_base64(self): @@ -139,7 +137,7 @@ class TestExtractB64AndFormat: # Base64 can include + and / characters original_data = bytes(range(256)) # All byte values b64_data = base64.b64encode(original_data).decode() - data_uri = f"data:image/png;base64,{b64_data}" + data_uri = f'data:image/png;base64,{b64_data}' result_b64, result_format = await extract_b64_and_format(data_uri) @@ -150,9 +148,9 @@ class TestExtractB64AndFormat: @pytest.mark.asyncio async def test_empty_base64(self): """Handle empty base64 string.""" - data_uri = "data:image/png;base64," + data_uri = 'data:image/png;base64,' result_b64, result_format = await extract_b64_and_format(data_uri) - assert result_b64 == "" - assert result_format == "png" + assert result_b64 == '' + assert result_format == 'png' diff --git a/tests/unit_tests/utils/test_importutil.py b/tests/unit_tests/utils/test_importutil.py index b0ea0ad7a..cfd7fc23e 100644 --- a/tests/unit_tests/utils/test_importutil.py +++ b/tests/unit_tests/utils/test_importutil.py @@ -23,52 +23,52 @@ class TestImportDir: def test_calls_importlib_for_each_python_file(self, tmp_path): """Should call importlib.import_module for each .py file.""" - module_dir = tmp_path / "test_modules" + module_dir = tmp_path / 'test_modules' module_dir.mkdir() - (module_dir / "__init__.py").write_text("") - (module_dir / "module_a.py").write_text("VALUE_A = 'a'\n") - (module_dir / "module_b.py").write_text("VALUE_B = 'b'\n") - (module_dir / "readme.txt").write_text("not a module") + (module_dir / '__init__.py').write_text('') + (module_dir / 'module_a.py').write_text("VALUE_A = 'a'\n") + (module_dir / 'module_b.py').write_text("VALUE_B = 'b'\n") + (module_dir / 'readme.txt').write_text('not a module') from langbot.pkg.utils import importutil - with patch.object(importlib, "import_module") as mock_import: - importutil.import_dir(str(module_dir), path_prefix="test_prefix.") + with patch.object(importlib, 'import_module') as mock_import: + importutil.import_dir(str(module_dir), path_prefix='test_prefix.') # Should call import_module for each .py file (excluding __init__.py) assert mock_import.call_count == 2 def test_skips_init_py(self, tmp_path): """Should skip __init__.py when importing.""" - module_dir = tmp_path / "test_modules" + module_dir = tmp_path / 'test_modules' module_dir.mkdir() - (module_dir / "__init__.py").write_text("") - (module_dir / "regular.py").write_text("VALUE = 1\n") + (module_dir / '__init__.py').write_text('') + (module_dir / 'regular.py').write_text('VALUE = 1\n') from langbot.pkg.utils import importutil - with patch.object(importlib, "import_module") as mock_import: - importutil.import_dir(str(module_dir), path_prefix="test_prefix.") + with patch.object(importlib, 'import_module') as mock_import: + importutil.import_dir(str(module_dir), path_prefix='test_prefix.') # __init__.py should be skipped mock_import.assert_called_once() # The call should not include __init__ call_args = mock_import.call_args[0][0] - assert "__init__" not in call_args + assert '__init__' not in call_args def test_ignores_non_py_files(self, tmp_path): """Should ignore non-.py files.""" - module_dir = tmp_path / "test_modules" + module_dir = tmp_path / 'test_modules' module_dir.mkdir() - (module_dir / "module.py").write_text("VALUE = 1\n") - (module_dir / "readme.txt").write_text("text") - (module_dir / "data.json").write_text("{}") + (module_dir / 'module.py').write_text('VALUE = 1\n') + (module_dir / 'readme.txt').write_text('text') + (module_dir / 'data.json').write_text('{}') from langbot.pkg.utils import importutil - with patch.object(importlib, "import_module") as mock_import: - importutil.import_dir(str(module_dir), path_prefix="test_prefix.") + with patch.object(importlib, 'import_module') as mock_import: + importutil.import_dir(str(module_dir), path_prefix='test_prefix.') # Only .py files should be imported assert mock_import.call_count == 1 @@ -79,14 +79,14 @@ class TestImportModulesInPkg: def test_imports_modules_from_package(self, tmp_path): """Should import all modules from a package object.""" mock_pkg = MagicMock() - mock_pkg.__file__ = str(tmp_path / "__init__.py") + mock_pkg.__file__ = str(tmp_path / '__init__.py') - (tmp_path / "__init__.py").write_text("") - (tmp_path / "mod1.py").write_text("MOD1 = 1\n") + (tmp_path / '__init__.py').write_text('') + (tmp_path / 'mod1.py').write_text('MOD1 = 1\n') from langbot.pkg.utils import importutil - with patch.object(importutil, "import_dir") as mock_import_dir: + with patch.object(importutil, 'import_dir') as mock_import_dir: importutil.import_modules_in_pkg(mock_pkg) mock_import_dir.assert_called_once() call_path = mock_import_dir.call_args[0][0] @@ -101,11 +101,11 @@ class TestImportModulesInPkgs: from langbot.pkg.utils import importutil mock_pkg1 = MagicMock() - mock_pkg1.__file__ = "/path/to/pkg1/__init__.py" + mock_pkg1.__file__ = '/path/to/pkg1/__init__.py' mock_pkg2 = MagicMock() - mock_pkg2.__file__ = "/path/to/pkg2/__init__.py" + mock_pkg2.__file__ = '/path/to/pkg2/__init__.py' - with patch.object(importutil, "import_modules_in_pkg") as mock_import: + with patch.object(importutil, 'import_modules_in_pkg') as mock_import: importutil.import_modules_in_pkgs([mock_pkg1, mock_pkg2]) assert mock_import.call_count == 2 @@ -116,18 +116,18 @@ class TestImportDotStyleDir: def test_converts_dot_notation_to_path(self, tmp_path): """Should convert dot notation to path and import.""" # Create structure matching the dot notation - (tmp_path / "my").mkdir() - (tmp_path / "my" / "pkg").mkdir() - (tmp_path / "my" / "pkg" / "test").mkdir() + (tmp_path / 'my').mkdir() + (tmp_path / 'my' / 'pkg').mkdir() + (tmp_path / 'my' / 'pkg' / 'test').mkdir() from langbot.pkg.utils import importutil - with patch.object(importutil, "import_dir") as mock_import_dir: - importutil.import_dot_style_dir("my.pkg.test") + with patch.object(importutil, 'import_dir') as mock_import_dir: + importutil.import_dot_style_dir('my.pkg.test') # The path should be converted using os.path.join call_path = mock_import_dir.call_args[0][0] # Should contain the path components joined - assert "my" in call_path + assert 'my' in call_path class TestReadResourceFile: @@ -137,16 +137,16 @@ class TestReadResourceFile: """Should read content from a resource file.""" from langbot.pkg.utils import importutil - content = importutil.read_resource_file("templates/config.yaml") - assert "admins:" in content - assert "edition: community" in content + content = importutil.read_resource_file('templates/config.yaml') + assert 'api:' in content + assert 'edition: community' in content def test_raises_for_nonexistent_file(self): """Should raise exception for non-existent resource file.""" from langbot.pkg.utils import importutil with pytest.raises((FileNotFoundError, Exception)): - importutil.read_resource_file("nonexistent/path/file.txt") + importutil.read_resource_file('nonexistent/path/file.txt') class TestReadResourceFileBytes: @@ -156,16 +156,16 @@ class TestReadResourceFileBytes: """Should read content as bytes from a resource file.""" from langbot.pkg.utils import importutil - content = importutil.read_resource_file_bytes("templates/config.yaml") - assert b"admins:" in content - assert b"edition: community" in content + content = importutil.read_resource_file_bytes('templates/config.yaml') + assert b'api:' in content + assert b'edition: community' in content def test_raises_for_nonexistent_file_bytes(self): """Should raise exception for non-existent resource file.""" from langbot.pkg.utils import importutil with pytest.raises((FileNotFoundError, Exception)): - importutil.read_resource_file_bytes("nonexistent/path/file.txt") + importutil.read_resource_file_bytes('nonexistent/path/file.txt') class TestListResourceFiles: @@ -175,9 +175,9 @@ class TestListResourceFiles: """Should list files in a resource directory.""" from langbot.pkg.utils import importutil - files = importutil.list_resource_files("templates") - assert "config.yaml" in files - assert "default-pipeline-config.json" in files + files = importutil.list_resource_files('templates') + assert 'config.yaml' in files + assert 'default-pipeline-config.json' in files assert all(isinstance(file, str) for file in files) def test_raises_for_nonexistent_directory(self): @@ -185,8 +185,8 @@ class TestListResourceFiles: from langbot.pkg.utils import importutil with pytest.raises((FileNotFoundError, Exception)): - importutil.list_resource_files("nonexistent_directory_xyz") + importutil.list_resource_files('nonexistent_directory_xyz') -if __name__ == "__main__": - pytest.main([__file__, "-v"]) +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/tests/unit_tests/utils/test_paths.py b/tests/unit_tests/utils/test_paths.py index 390c82702..0043a3338 100644 --- a/tests/unit_tests/utils/test_paths.py +++ b/tests/unit_tests/utils/test_paths.py @@ -11,7 +11,6 @@ Uses tmp_path for file system isolation where applicable. import os import pytest -from unittest.mock import patch class TestCheckIfSourceInstall: @@ -19,7 +18,7 @@ class TestCheckIfSourceInstall: def test_returns_true_for_source_install(self, tmp_path, monkeypatch): """Should return True when main.py with LangBot marker exists.""" - main_py = tmp_path / "main.py" + main_py = tmp_path / 'main.py' main_py.write_text('# LangBot/main.py\n# This is the entry point') monkeypatch.chdir(tmp_path) @@ -33,52 +32,14 @@ class TestCheckIfSourceInstall: paths._is_source_install = None - def test_returns_false_when_no_main_py(self, tmp_path, monkeypatch): - """Should return False when main.py doesn't exist.""" - monkeypatch.chdir(tmp_path) - - from langbot.pkg.utils import paths - - paths._is_source_install = None - - result = paths._check_if_source_install() - assert result is False - - paths._is_source_install = None - - def test_returns_false_when_main_py_without_marker(self, tmp_path, monkeypatch): - """Should return False when main.py exists but lacks LangBot marker.""" - main_py = tmp_path / "main.py" - main_py.write_text('# Some other project\nprint("hello")') - - monkeypatch.chdir(tmp_path) - - from langbot.pkg.utils import paths - - paths._is_source_install = None - - result = paths._check_if_source_install() - assert result is False - - paths._is_source_install = None - - def test_handles_io_error_gracefully(self, tmp_path, monkeypatch): - """Should return False when main.py cannot be read.""" - main_py = tmp_path / "main.py" - main_py.write_text('# LangBot/main.py\n') - - monkeypatch.chdir(tmp_path) - - from langbot.pkg.utils import paths - - paths._is_source_install = None - - # Patch open to raise IOError - with patch("builtins.open", side_effect=IOError("Cannot read")): - result = paths._check_if_source_install() - assert result is False - - paths._is_source_install = None + # Note: ``_check_if_source_install`` was refactored to walk + # ``Path(__file__).resolve().parents`` looking for ``pyproject.toml`` + + # ``main.py`` instead of relying on the cwd. That makes it robust to where + # the process is launched from but also means the old "cwd doesn't have + # main.py" / "main.py without marker" / "IOError on read" cases no longer + # apply — there's no file read at all. The corresponding negative tests + # were removed; ``test_returns_true_for_source_install`` still exercises + # the positive path because the repo checkout itself is a source install. class TestGetFrontendPath: @@ -92,16 +53,16 @@ class TestGetFrontendPath: result = paths.get_frontend_path() # The result should contain web/dist or be an absolute path to it - assert "web/dist" in result or result.endswith("dist") + assert 'web/dist' in result or result.endswith('dist') paths._is_source_install = None def test_finds_dist_directory_in_source_mode(self, tmp_path, monkeypatch): """Should find web/dist when running from source mode.""" - main_py = tmp_path / "main.py" + main_py = tmp_path / 'main.py' main_py.write_text('# LangBot/main.py\n') - web_dist = tmp_path / "web" / "dist" + web_dist = tmp_path / 'web' / 'dist' web_dist.mkdir(parents=True) monkeypatch.chdir(tmp_path) @@ -111,18 +72,18 @@ class TestGetFrontendPath: paths._is_source_install = None result = paths.get_frontend_path() - assert result == "web/dist" + assert result == 'web/dist' paths._is_source_install = None def test_prefers_dist_over_out_in_source_mode(self, tmp_path, monkeypatch): """Should prefer web/dist over web/out when both exist in source mode.""" - main_py = tmp_path / "main.py" + main_py = tmp_path / 'main.py' main_py.write_text('# LangBot/main.py\n') - web_dist = tmp_path / "web" / "dist" + web_dist = tmp_path / 'web' / 'dist' web_dist.mkdir(parents=True) - web_out = tmp_path / "web" / "out" + web_out = tmp_path / 'web' / 'out' web_out.mkdir(parents=True) monkeypatch.chdir(tmp_path) @@ -132,7 +93,7 @@ class TestGetFrontendPath: paths._is_source_install = None result = paths.get_frontend_path() - assert result == "web/dist" + assert result == 'web/dist' paths._is_source_install = None @@ -148,19 +109,19 @@ class TestGetResourcePath: paths._is_source_install = None - result = paths.get_resource_path("nonexistent/file.txt") - assert result == "nonexistent/file.txt" + result = paths.get_resource_path('nonexistent/file.txt') + assert result == 'nonexistent/file.txt' paths._is_source_install = None def test_finds_resource_in_current_directory_source_mode(self, tmp_path, monkeypatch): """Should find resource in current directory when in source mode.""" - main_py = tmp_path / "main.py" + main_py = tmp_path / 'main.py' main_py.write_text('# LangBot/main.py\n') - resource_file = tmp_path / "templates" / "config.yaml" + resource_file = tmp_path / 'templates' / 'config.yaml' resource_file.parent.mkdir(parents=True, exist_ok=True) - resource_file.write_text("test: value") + resource_file.write_text('test: value') monkeypatch.chdir(tmp_path) @@ -168,18 +129,18 @@ class TestGetResourcePath: paths._is_source_install = None - result = paths.get_resource_path("templates/config.yaml") + result = paths.get_resource_path('templates/config.yaml') assert os.path.exists(result) paths._is_source_install = None def test_returns_relative_path_in_source_mode(self, tmp_path, monkeypatch): """Should return relative path if resource exists in source mode.""" - main_py = tmp_path / "main.py" + main_py = tmp_path / 'main.py' main_py.write_text('# LangBot/main.py\n') - resource_file = tmp_path / "test_resource.txt" - resource_file.write_text("test content") + resource_file = tmp_path / 'test_resource.txt' + resource_file.write_text('test content') monkeypatch.chdir(tmp_path) @@ -187,8 +148,8 @@ class TestGetResourcePath: paths._is_source_install = None - result = paths.get_resource_path("test_resource.txt") - assert result == "test_resource.txt" + result = paths.get_resource_path('test_resource.txt') + assert result == 'test_resource.txt' paths._is_source_install = None @@ -198,7 +159,7 @@ class TestPathFunctionsCaching: def test_source_install_cache_is_used(self, tmp_path, monkeypatch): """_check_if_source_install should use cached result.""" - main_py = tmp_path / "main.py" + main_py = tmp_path / 'main.py' main_py.write_text('# LangBot/main.py\n') monkeypatch.chdir(tmp_path) @@ -219,5 +180,5 @@ class TestPathFunctionsCaching: paths._is_source_install = None -if __name__ == "__main__": - pytest.main([__file__, "-v"]) \ No newline at end of file +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/tests/unit_tests/utils/test_platform.py b/tests/unit_tests/utils/test_platform.py index 76a64a052..4f3e1a5da 100644 --- a/tests/unit_tests/utils/test_platform.py +++ b/tests/unit_tests/utils/test_platform.py @@ -5,6 +5,7 @@ Tests cover: - Docker environment detection - WebSocket plugin runtime mode """ + from __future__ import annotations import os @@ -86,4 +87,4 @@ class TestGetPlatform: assert platform_module.use_websocket_to_connect_plugin_runtime() is True # Restore - platform_module.standalone_runtime = original \ No newline at end of file + platform_module.standalone_runtime = original diff --git a/tests/unit_tests/utils/test_proxy.py b/tests/unit_tests/utils/test_proxy.py index 572375194..09bc44cc5 100644 --- a/tests/unit_tests/utils/test_proxy.py +++ b/tests/unit_tests/utils/test_proxy.py @@ -60,10 +60,12 @@ class TestProxyManager: async def test_initialize_config_overrides_env(self): """Config proxy overrides environment variables.""" - mock_app = self._create_mock_app(proxy_config={ - 'http': 'http://config-proxy:8080', - 'https': 'https://config-proxy:8443', - }) + mock_app = self._create_mock_app( + proxy_config={ + 'http': 'http://config-proxy:8080', + 'https': 'https://config-proxy:8443', + } + ) with patch.dict(os.environ, {'HTTP_PROXY': 'http://env-proxy:8080'}): pm = ProxyManager(mock_app) @@ -74,10 +76,12 @@ class TestProxyManager: async def test_initialize_sets_env_variables(self): """initialize sets proxy to environment variables.""" - mock_app = self._create_mock_app(proxy_config={ - 'http': 'http://test-proxy:8080', - 'https': 'https://test-proxy:8443', - }) + mock_app = self._create_mock_app( + proxy_config={ + 'http': 'http://test-proxy:8080', + 'https': 'https://test-proxy:8443', + } + ) pm = ProxyManager(mock_app) await pm.initialize() @@ -143,9 +147,11 @@ class TestProxyManager: async def test_initialize_http_only_config(self): """initialize handles http-only config.""" - mock_app = self._create_mock_app(proxy_config={ - 'http': 'http://http-only:8080', - }) + mock_app = self._create_mock_app( + proxy_config={ + 'http': 'http://http-only:8080', + } + ) # Clear any existing proxy env vars env_backup = {} diff --git a/tests/unit_tests/utils/test_runner.py b/tests/unit_tests/utils/test_runner.py index 28f5d8e52..5fc092cf2 100644 --- a/tests/unit_tests/utils/test_runner.py +++ b/tests/unit_tests/utils/test_runner.py @@ -29,63 +29,63 @@ class TestGetRunnerCategory: def test_empty_url_returns_unknown(self): """Empty or None URL should return UNKNOWN.""" - assert get_runner_category("test", "") == RunnerCategory.UNKNOWN - assert get_runner_category("test", None) == RunnerCategory.UNKNOWN + assert get_runner_category('test', '') == RunnerCategory.UNKNOWN + assert get_runner_category('test', None) == RunnerCategory.UNKNOWN def test_localhost_returns_local(self): """localhost URL should be categorized as LOCAL.""" - assert get_runner_category("test", "http://localhost:3000") == RunnerCategory.LOCAL - assert get_runner_category("test", "https://localhost") == RunnerCategory.LOCAL + assert get_runner_category('test', 'http://localhost:3000') == RunnerCategory.LOCAL + assert get_runner_category('test', 'https://localhost') == RunnerCategory.LOCAL def test_127_0_0_1_returns_local(self): """127.0.0.1 URL should be categorized as LOCAL.""" - assert get_runner_category("test", "http://127.0.0.1:8080") == RunnerCategory.LOCAL - assert get_runner_category("test", "https://127.0.0.1") == RunnerCategory.LOCAL + assert get_runner_category('test', 'http://127.0.0.1:8080') == RunnerCategory.LOCAL + assert get_runner_category('test', 'https://127.0.0.1') == RunnerCategory.LOCAL def test_0_0_0_0_returns_local(self): """0.0.0.0 URL should be categorized as LOCAL.""" - assert get_runner_category("test", "http://0.0.0.0:8080") == RunnerCategory.LOCAL + assert get_runner_category('test', 'http://0.0.0.0:8080') == RunnerCategory.LOCAL def test_private_ip_192_168_returns_local(self): """192.168.x.x private IP should be categorized as LOCAL.""" - assert get_runner_category("test", "http://192.168.1.1:3000") == RunnerCategory.LOCAL - assert get_runner_category("test", "http://192.168.0.100") == RunnerCategory.LOCAL + assert get_runner_category('test', 'http://192.168.1.1:3000') == RunnerCategory.LOCAL + assert get_runner_category('test', 'http://192.168.0.100') == RunnerCategory.LOCAL def test_private_ip_10_returns_local(self): """10.x.x.x private IP should be categorized as LOCAL.""" - assert get_runner_category("test", "http://10.0.0.1:8080") == RunnerCategory.LOCAL - assert get_runner_category("test", "http://10.255.255.255") == RunnerCategory.LOCAL + assert get_runner_category('test', 'http://10.0.0.1:8080') == RunnerCategory.LOCAL + assert get_runner_category('test', 'http://10.255.255.255') == RunnerCategory.LOCAL def test_private_ip_172_16_31_returns_local(self): """172.16.x.x - 172.31.x.x private IP range should be categorized as LOCAL.""" - assert get_runner_category("test", "http://172.16.0.1:8080") == RunnerCategory.LOCAL - assert get_runner_category("test", "http://172.20.0.1") == RunnerCategory.LOCAL - assert get_runner_category("test", "http://172.31.255.255") == RunnerCategory.LOCAL + assert get_runner_category('test', 'http://172.16.0.1:8080') == RunnerCategory.LOCAL + assert get_runner_category('test', 'http://172.20.0.1') == RunnerCategory.LOCAL + assert get_runner_category('test', 'http://172.31.255.255') == RunnerCategory.LOCAL def test_n8n_cloud_returns_cloud(self): """n8n.cloud domain should be categorized as CLOUD.""" - assert get_runner_category("test", "https://myinstance.n8n.cloud") == RunnerCategory.CLOUD - assert get_runner_category("test", "https://test.n8n.io") == RunnerCategory.CLOUD + assert get_runner_category('test', 'https://myinstance.n8n.cloud') == RunnerCategory.CLOUD + assert get_runner_category('test', 'https://test.n8n.io') == RunnerCategory.CLOUD def test_dify_cloud_returns_cloud(self): """Dify cloud domains should be categorized as CLOUD.""" - assert get_runner_category("test", "https://api.dify.ai/v1") == RunnerCategory.CLOUD - assert get_runner_category("test", "https://cloud.dify.ai") == RunnerCategory.CLOUD + assert get_runner_category('test', 'https://api.dify.ai/v1') == RunnerCategory.CLOUD + assert get_runner_category('test', 'https://cloud.dify.ai') == RunnerCategory.CLOUD def test_coze_cloud_returns_cloud(self): """Coze domains should be categorized as CLOUD.""" - assert get_runner_category("test", "https://api.coze.com") == RunnerCategory.CLOUD - assert get_runner_category("test", "https://api.coze.cn") == RunnerCategory.CLOUD + assert get_runner_category('test', 'https://api.coze.com') == RunnerCategory.CLOUD + assert get_runner_category('test', 'https://api.coze.cn') == RunnerCategory.CLOUD def test_langflow_cloud_returns_cloud(self): """Langflow domains should be categorized as CLOUD.""" - assert get_runner_category("test", "https://cloud.langflow.ai") == RunnerCategory.CLOUD - assert get_runner_category("test", "https://test.langflow.org") == RunnerCategory.CLOUD + assert get_runner_category('test', 'https://cloud.langflow.ai') == RunnerCategory.CLOUD + assert get_runner_category('test', 'https://test.langflow.org') == RunnerCategory.CLOUD def test_other_url_returns_cloud(self): """Other URLs should default to CLOUD category.""" - assert get_runner_category("test", "https://example.com") == RunnerCategory.CLOUD - assert get_runner_category("test", "https://myserver.example.org") == RunnerCategory.CLOUD + assert get_runner_category('test', 'https://example.com') == RunnerCategory.CLOUD + assert get_runner_category('test', 'https://myserver.example.org') == RunnerCategory.CLOUD @pytest.mark.parametrize( 'runner_url', @@ -101,7 +101,7 @@ class TestGetRunnerCategory: ) def test_invalid_urls_return_unknown(self, runner_url): """Invalid or incomplete URLs should return UNKNOWN.""" - assert get_runner_category("test", runner_url) == RunnerCategory.UNKNOWN + assert get_runner_category('test', runner_url) == RunnerCategory.UNKNOWN def test_urlparse_exception_returns_unknown(self): """Exception during URL parsing should return UNKNOWN.""" @@ -109,15 +109,15 @@ class TestGetRunnerCategory: from langbot.pkg.utils import runner def mock_urlparse(url): - raise Exception("URL parsing failed") + raise Exception('URL parsing failed') - with patch("langbot.pkg.utils.runner.urlparse", side_effect=mock_urlparse): - result = runner.get_runner_category("test", "http://example.com") + with patch('langbot.pkg.utils.runner.urlparse', side_effect=mock_urlparse): + result = runner.get_runner_category('test', 'http://example.com') assert result == RunnerCategory.UNKNOWN def test_url_without_scheme_returns_unknown(self): """URL without scheme should return UNKNOWN.""" - assert get_runner_category("test", "example.com") == RunnerCategory.UNKNOWN + assert get_runner_category('test', 'example.com') == RunnerCategory.UNKNOWN @pytest.mark.parametrize( 'runner_url', @@ -146,20 +146,21 @@ class TestGetRunnerCategory: """Domain names that only look like private IP prefixes should not be LOCAL.""" assert get_runner_category('langflow-api', runner_url) == RunnerCategory.CLOUD + class TestIsCloudRunner: """Test is_cloud_runner helper function.""" def test_cloud_runner_returns_true(self): """Cloud URL should return True.""" - assert is_cloud_runner("test", "https://api.dify.ai") is True + assert is_cloud_runner('test', 'https://api.dify.ai') is True def test_local_runner_returns_false(self): """Local URL should return False.""" - assert is_cloud_runner("test", "http://localhost:3000") is False + assert is_cloud_runner('test', 'http://localhost:3000') is False def test_unknown_returns_false(self): """Unknown category should return False.""" - assert is_cloud_runner("test", None) is False + assert is_cloud_runner('test', None) is False class TestIsLocalRunner: @@ -167,15 +168,15 @@ class TestIsLocalRunner: def test_local_runner_returns_true(self): """Local URL should return True.""" - assert is_local_runner("test", "http://localhost:3000") is True + assert is_local_runner('test', 'http://localhost:3000') is True def test_cloud_runner_returns_false(self): """Cloud URL should return False.""" - assert is_local_runner("test", "https://api.dify.ai") is False + assert is_local_runner('test', 'https://api.dify.ai') is False def test_unknown_returns_false(self): """Unknown category should return False.""" - assert is_local_runner("test", None) is False + assert is_local_runner('test', None) is False class TestGetRunnerInfo: @@ -183,17 +184,17 @@ class TestGetRunnerInfo: def test_returns_dict_with_expected_keys(self): """Should return dict with name, url, and category keys.""" - info = get_runner_info("my-runner", "http://localhost:3000") - assert "name" in info - assert "url" in info - assert "category" in info + info = get_runner_info('my-runner', 'http://localhost:3000') + assert 'name' in info + assert 'url' in info + assert 'category' in info def test_includes_correct_values(self): """Should include correct values in dict.""" - info = get_runner_info("my-runner", "http://localhost:3000") - assert info["name"] == "my-runner" - assert info["url"] == "http://localhost:3000" - assert info["category"] == RunnerCategory.LOCAL + info = get_runner_info('my-runner', 'http://localhost:3000') + assert info['name'] == 'my-runner' + assert info['url'] == 'http://localhost:3000' + assert info['category'] == RunnerCategory.LOCAL class TestExtractRunnerUrl: @@ -203,74 +204,58 @@ class TestExtractRunnerUrl: """Should extract base-url from dify-service-api config.""" runner = Mock() runner.pipeline_config = {} - pipeline_config = { - "ai": { - "dify-service-api": {"base-url": "https://api.dify.ai"} - } - } - url = extract_runner_url("dify-service-api", runner, pipeline_config) - assert url == "https://api.dify.ai" + pipeline_config = {'ai': {'dify-service-api': {'base-url': 'https://api.dify.ai'}}} + url = extract_runner_url('dify-service-api', runner, pipeline_config) + assert url == 'https://api.dify.ai' def test_n8n_service_api_extracts_url(self): """Should extract webhook-url from n8n-service-api config.""" runner = Mock() runner.pipeline_config = {} - pipeline_config = { - "ai": { - "n8n-service-api": {"webhook-url": "https://my.n8n.cloud/webhook"} - } - } - url = extract_runner_url("n8n-service-api", runner, pipeline_config) - assert url == "https://my.n8n.cloud/webhook" + pipeline_config = {'ai': {'n8n-service-api': {'webhook-url': 'https://my.n8n.cloud/webhook'}}} + url = extract_runner_url('n8n-service-api', runner, pipeline_config) + assert url == 'https://my.n8n.cloud/webhook' def test_coze_api_extracts_url(self): """Should extract api-base from coze-api config.""" runner = Mock() runner.pipeline_config = {} - pipeline_config = { - "ai": { - "coze-api": {"api-base": "https://api.coze.com"} - } - } - url = extract_runner_url("coze-api", runner, pipeline_config) - assert url == "https://api.coze.com" + pipeline_config = {'ai': {'coze-api': {'api-base': 'https://api.coze.com'}}} + url = extract_runner_url('coze-api', runner, pipeline_config) + assert url == 'https://api.coze.com' def test_langflow_api_extracts_url(self): """Should extract base-url from langflow-api config.""" runner = Mock() runner.pipeline_config = {} - pipeline_config = { - "ai": { - "langflow-api": {"base-url": "https://cloud.langflow.ai"} - } - } - url = extract_runner_url("langflow-api", runner, pipeline_config) - assert url == "https://cloud.langflow.ai" + pipeline_config = {'ai': {'langflow-api': {'base-url': 'https://cloud.langflow.ai'}}} + url = extract_runner_url('langflow-api', runner, pipeline_config) + assert url == 'https://cloud.langflow.ai' def test_unknown_runner_returns_none(self): """Unknown runner name should return None.""" runner = Mock() runner.pipeline_config = {} pipeline_config = {} - url = extract_runner_url("unknown-runner", runner, pipeline_config) + url = extract_runner_url('unknown-runner', runner, pipeline_config) assert url is None def test_none_runner_returns_none(self): """None runner should return None.""" - url = extract_runner_url("test", None, {}) + url = extract_runner_url('test', None, {}) assert url is None def test_runner_without_pipeline_config_returns_none(self): """Runner without pipeline_config attribute should return None.""" runner = Mock(spec=[]) # Empty spec means no attributes - url = extract_runner_url("test", runner, {}) + url = extract_runner_url('test', runner, {}) assert url is None def test_none_pipeline_config_returns_none(self): """None pipeline_config should return None.""" runner = Mock() runner.pipeline_config = {} - url = extract_runner_url("dify-service-api", runner, None) + url = extract_runner_url('dify-service-api', runner, None) assert url is None def test_missing_ai_config_returns_none(self): @@ -278,7 +263,7 @@ class TestExtractRunnerUrl: runner = Mock() runner.pipeline_config = {} pipeline_config = {} - url = extract_runner_url("dify-service-api", runner, pipeline_config) + url = extract_runner_url('dify-service-api', runner, pipeline_config) assert url is None @@ -289,19 +274,15 @@ class TestGetRunnerCategoryFromRunner: """Should extract URL and return correct category.""" runner = Mock() runner.pipeline_config = {} - pipeline_config = { - "ai": { - "dify-service-api": {"base-url": "https://api.dify.ai"} - } - } - category = get_runner_category_from_runner("dify-service-api", runner, pipeline_config) + pipeline_config = {'ai': {'dify-service-api': {'base-url': 'https://api.dify.ai'}}} + category = get_runner_category_from_runner('dify-service-api', runner, pipeline_config) assert category == RunnerCategory.CLOUD def test_returns_unknown_for_missing_url(self): """Should return UNKNOWN when URL cannot be extracted.""" runner = Mock() runner.pipeline_config = {} - category = get_runner_category_from_runner("unknown", runner, {}) + category = get_runner_category_from_runner('unknown', runner, {}) assert category == RunnerCategory.UNKNOWN @@ -310,9 +291,9 @@ class TestConstants: def test_runner_category_constants(self): """RunnerCategory should have LOCAL, CLOUD, UNKNOWN.""" - assert RunnerCategory.LOCAL == "local" - assert RunnerCategory.CLOUD == "cloud" - assert RunnerCategory.UNKNOWN == "unknown" + assert RunnerCategory.LOCAL == 'local' + assert RunnerCategory.CLOUD == 'cloud' + assert RunnerCategory.UNKNOWN == 'unknown' def test_cloud_domains_not_empty(self): """CLOUD_DOMAINS should not be empty.""" @@ -323,5 +304,5 @@ class TestConstants: assert len(LOCAL_PATTERNS) > 0 -if __name__ == "__main__": - pytest.main([__file__, "-v"]) +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/tests/unit_tests/utils/test_version.py b/tests/unit_tests/utils/test_version.py deleted file mode 100644 index df698caf8..000000000 --- a/tests/unit_tests/utils/test_version.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -Unit tests for version utility functions. - -Tests version comparison logic without network calls. -""" - -from __future__ import annotations - -from unittest.mock import Mock - -from langbot.pkg.utils.version import VersionManager - - -class TestVersionComparison: - """Tests for version comparison functions.""" - - def _create_version_manager(self): - """Create a VersionManager with mock app.""" - mock_app = Mock() - mock_app.proxy_mgr = Mock() - mock_app.proxy_mgr.get_forward_providers = Mock(return_value={}) - mock_app.logger = Mock() - return VersionManager(mock_app) - - def test_is_newer_same_version(self): - """is_newer returns False for same version.""" - vm = self._create_version_manager() - result = vm.is_newer('v1.0.0', 'v1.0.0') - assert result is False - - def test_is_newer_different_major_version(self): - """is_newer returns False for different major version.""" - # Note: is_newer ignores major version changes - vm = self._create_version_manager() - result = vm.is_newer('v2.0.0', 'v1.0.0') - assert result is False - - def test_is_newer_minor_update(self): - """is_newer returns True for minor update within same major.""" - vm = self._create_version_manager() - result = vm.is_newer('v1.1.0', 'v1.0.0') - assert result is True - - def test_is_newer_patch_update(self): - """is_newer returns True for patch update within same major.""" - vm = self._create_version_manager() - result = vm.is_newer('v1.0.1', 'v1.0.0') - assert result is True - - def test_is_newer_with_fourth_segment(self): - """is_newer ignores fourth version segment.""" - # Both have same first 3 segments - vm = self._create_version_manager() - result = vm.is_newer('v1.0.0.1', 'v1.0.0.0') - assert result is False - - def test_is_newer_short_version(self): - """is_newer handles short version numbers.""" - vm = self._create_version_manager() - result = vm.is_newer('v1.0', 'v1.0') - assert result is False - - def test_is_newer_older_version(self): - """is_newer returns True when new > old.""" - vm = self._create_version_manager() - result = vm.is_newer('v1.2.0', 'v1.1.0') - assert result is True - - -class TestCompareVersionStr: - """Tests for compare_version_str static method.""" - - def test_compare_equal_versions(self): - """Equal versions return 0.""" - result = VersionManager.compare_version_str('v1.0.0', 'v1.0.0') - assert result == 0 - - def test_compare_without_v_prefix(self): - """Versions without v prefix work the same.""" - result = VersionManager.compare_version_str('1.0.0', '1.0.0') - assert result == 0 - - def test_compare_mixed_prefix(self): - """Mixed v prefix works correctly.""" - result = VersionManager.compare_version_str('v1.0.0', '1.0.0') - assert result == 0 - - def test_compare_first_greater(self): - """First version greater returns 1.""" - result = VersionManager.compare_version_str('v1.1.0', 'v1.0.0') - assert result == 1 - - def test_compare_first_smaller(self): - """First version smaller returns -1.""" - result = VersionManager.compare_version_str('v1.0.0', 'v1.1.0') - assert result == -1 - - def test_compare_different_lengths(self): - """Different length versions are padded with zeros.""" - result = VersionManager.compare_version_str('v1.0', 'v1.0.0') - assert result == 0 - - def test_compare_shorter_greater(self): - """Shorter version padded, first still greater.""" - result = VersionManager.compare_version_str('v1.1', 'v1.0.0') - assert result == 1 - - def test_compare_longer_greater(self): - """Longer version, first smaller.""" - result = VersionManager.compare_version_str('v1.0', 'v1.0.1') - assert result == -1 - - def test_compare_major_version(self): - """Major version comparison.""" - result = VersionManager.compare_version_str('v2.0.0', 'v1.9.9') - assert result == 1 - - def test_compare_minor_version(self): - """Minor version comparison.""" - result = VersionManager.compare_version_str('v1.5.0', 'v1.4.9') - assert result == 1 - - def test_compare_patch_version(self): - """Patch version comparison.""" - result = VersionManager.compare_version_str('v1.0.1', 'v1.0.0') - assert result == 1 - - def test_compare_four_segments(self): - """Four segment version comparison.""" - result = VersionManager.compare_version_str('v1.0.0.1', 'v1.0.0.0') - assert result == 1 - - def test_compare_long_versions(self): - """Long version strings work correctly.""" - result = VersionManager.compare_version_str('v1.2.3.4.5', 'v1.2.3.4.4') - assert result == 1 diff --git a/tests/unit_tests/vector/test_filter_utils.py b/tests/unit_tests/vector/test_filter_utils.py index f4eefb284..2bbf4a1c9 100644 --- a/tests/unit_tests/vector/test_filter_utils.py +++ b/tests/unit_tests/vector/test_filter_utils.py @@ -68,11 +68,7 @@ class TestNormalizeFilter: def test_normalize_filter_multiple_conditions(self): """Multiple top-level keys are AND-ed (returned as multiple triples).""" - result = normalize_filter({ - 'file_id': 'abc', - 'status': {'$ne': 'deleted'}, - 'created_at': {'$gte': 1700000000} - }) + result = normalize_filter({'file_id': 'abc', 'status': {'$ne': 'deleted'}, 'created_at': {'$gte': 1700000000}}) assert len(result) == 3 # Order should match dict iteration order @@ -149,11 +145,7 @@ class TestStripUnsupportedFields: ('file_id', '$eq', 'def'), ] - result = strip_unsupported_fields( - triples, - {'file_id', 'chunk_uuid'}, - field_aliases={'uuid': 'chunk_uuid'} - ) + result = strip_unsupported_fields(triples, {'file_id', 'chunk_uuid'}, field_aliases={'uuid': 'chunk_uuid'}) assert len(result) == 2 # 'uuid' should be resolved to 'chunk_uuid' @@ -169,7 +161,7 @@ class TestStripUnsupportedFields: result = strip_unsupported_fields( triples, {'file_id'}, # chunk_uuid not supported - field_aliases={'uuid': 'chunk_uuid'} + field_aliases={'uuid': 'chunk_uuid'}, ) assert result == [] @@ -207,4 +199,5 @@ class TestSupportedOpsConstant: def test_supported_ops_is_frozenset(self): """SUPPORTED_OPS is a frozenset for immutability.""" from collections.abc import Set - assert isinstance(SUPPORTED_OPS, Set) \ No newline at end of file + + assert isinstance(SUPPORTED_OPS, Set) diff --git a/tests/unit_tests/vector/test_mgr.py b/tests/unit_tests/vector/test_mgr.py index bf588a53c..5c8927b54 100644 --- a/tests/unit_tests/vector/test_mgr.py +++ b/tests/unit_tests/vector/test_mgr.py @@ -33,7 +33,7 @@ class TestVectorDBManagerInitialization: mocks['langbot.pkg.core.app'] = MagicMock() # Mock all VDB backend implementations - for backend in ['chroma', 'qdrant', 'seekdb', 'milvus', 'pgvector_db']: + for backend in ['chroma', 'qdrant', 'seekdb', 'milvus', 'pgvector_db', 'valkey_search']: mocks[f'langbot.pkg.vector.vdbs.{backend}'] = MagicMock() return mocks @@ -55,6 +55,7 @@ class TestVectorDBManagerInitialization: # Run initialize synchronously for test import asyncio + asyncio.get_event_loop().run_until_complete(mgr.initialize()) # Chroma should be instantiated @@ -76,6 +77,7 @@ class TestVectorDBManagerInitialization: mgr = VectorDBManager(mock_app) import asyncio + asyncio.get_event_loop().run_until_complete(mgr.initialize()) mock_chroma_class.assert_called_once_with(mock_app) @@ -96,6 +98,7 @@ class TestVectorDBManagerInitialization: mgr = VectorDBManager(mock_app) import asyncio + asyncio.get_event_loop().run_until_complete(mgr.initialize()) mock_qdrant_class.assert_called_once_with(mock_app) @@ -115,19 +118,35 @@ class TestVectorDBManagerInitialization: mgr = VectorDBManager(mock_app) import asyncio + asyncio.get_event_loop().run_until_complete(mgr.initialize()) mock_seekdb_class.assert_called_once_with(mock_app) + def test_initialize_valkey_search_backend(self): + """Valkey Search config uses ValkeySearchVectorDatabase backend.""" + vdb_config = {'use': 'valkey_search'} + mock_app = self._create_mock_app(vdb_config) + + mocks = self._make_vector_import_mocks() + mock_valkey_class = MagicMock() + mocks['langbot.pkg.vector.vdbs.valkey_search'].ValkeySearchVectorDatabase = mock_valkey_class + + with isolated_sys_modules(mocks): + from langbot.pkg.vector.mgr import VectorDBManager + + mgr = VectorDBManager(mock_app) + + import asyncio + asyncio.get_event_loop().run_until_complete(mgr.initialize()) + + mock_valkey_class.assert_called_once_with(mock_app) + def test_initialize_milvus_backend_with_uri(self): """Milvus config with custom URI.""" vdb_config = { 'use': 'milvus', - 'milvus': { - 'uri': 'http://localhost:19530', - 'token': 'root:Milvus', - 'db_name': 'langbot_db' - } + 'milvus': {'uri': 'http://localhost:19530', 'token': 'root:Milvus', 'db_name': 'langbot_db'}, } mock_app = self._create_mock_app(vdb_config) @@ -141,13 +160,11 @@ class TestVectorDBManagerInitialization: mgr = VectorDBManager(mock_app) import asyncio + asyncio.get_event_loop().run_until_complete(mgr.initialize()) mock_milvus_class.assert_called_once_with( - mock_app, - uri='http://localhost:19530', - token='root:Milvus', - db_name='langbot_db' + mock_app, uri='http://localhost:19530', token='root:Milvus', db_name='langbot_db' ) def test_initialize_milvus_backend_defaults(self): @@ -165,24 +182,15 @@ class TestVectorDBManagerInitialization: mgr = VectorDBManager(mock_app) import asyncio + asyncio.get_event_loop().run_until_complete(mgr.initialize()) # Should use default values - mock_milvus_class.assert_called_once_with( - mock_app, - uri='./data/milvus.db', - token=None, - db_name='default' - ) + mock_milvus_class.assert_called_once_with(mock_app, uri='./data/milvus.db', token=None, db_name='default') def test_initialize_pgvector_with_connection_string(self): """pgvector with connection string.""" - vdb_config = { - 'use': 'pgvector', - 'pgvector': { - 'connection_string': 'postgresql://user:pass@host:5432/langbot' - } - } + vdb_config = {'use': 'pgvector', 'pgvector': {'connection_string': 'postgresql://user:pass@host:5432/langbot'}} mock_app = self._create_mock_app(vdb_config) mocks = self._make_vector_import_mocks() @@ -195,11 +203,11 @@ class TestVectorDBManagerInitialization: mgr = VectorDBManager(mock_app) import asyncio + asyncio.get_event_loop().run_until_complete(mgr.initialize()) mock_pgvector_class.assert_called_once_with( - mock_app, - connection_string='postgresql://user:pass@host:5432/langbot' + mock_app, connection_string='postgresql://user:pass@host:5432/langbot' ) def test_initialize_pgvector_with_individual_params(self): @@ -211,8 +219,8 @@ class TestVectorDBManagerInitialization: 'port': 5433, 'database': 'vectordb', 'user': 'admin', - 'password': 'secret' - } + 'password': 'secret', + }, } mock_app = self._create_mock_app(vdb_config) @@ -226,15 +234,11 @@ class TestVectorDBManagerInitialization: mgr = VectorDBManager(mock_app) import asyncio + asyncio.get_event_loop().run_until_complete(mgr.initialize()) mock_pgvector_class.assert_called_once_with( - mock_app, - host='db.example.com', - port=5433, - database='vectordb', - user='admin', - password='secret' + mock_app, host='db.example.com', port=5433, database='vectordb', user='admin', password='secret' ) def test_initialize_pgvector_defaults(self): @@ -252,15 +256,11 @@ class TestVectorDBManagerInitialization: mgr = VectorDBManager(mock_app) import asyncio + asyncio.get_event_loop().run_until_complete(mgr.initialize()) mock_pgvector_class.assert_called_once_with( - mock_app, - host='localhost', - port=5432, - database='langbot', - user='postgres', - password='postgres' + mock_app, host='localhost', port=5432, database='langbot', user='postgres', password='postgres' ) def test_initialize_unknown_backend_defaults_to_chroma(self): @@ -278,6 +278,7 @@ class TestVectorDBManagerInitialization: mgr = VectorDBManager(mock_app) import asyncio + asyncio.get_event_loop().run_until_complete(mgr.initialize()) mock_chroma_class.assert_called_once_with(mock_app) @@ -335,4 +336,4 @@ class TestVectorDBManagerProxies: mgr.vector_db = mock_vector_db result = mgr.get_supported_search_types() - assert result == ['vector', 'full_text'] \ No newline at end of file + assert result == ['vector', 'full_text'] diff --git a/tests/unit_tests/vector/test_valkey_search_filter.py b/tests/unit_tests/vector/test_valkey_search_filter.py new file mode 100644 index 000000000..7439712dd --- /dev/null +++ b/tests/unit_tests/vector/test_valkey_search_filter.py @@ -0,0 +1,388 @@ +"""Unit tests for the Valkey Search VDB backend's pure helpers. + +These tests exercise the filter-to-FT mapping, float32 packing, tag/text +escaping, FT.SEARCH reply parsing and the import guard. They run in the fast +CI lane and require NO running Valkey server. +""" + +from __future__ import annotations + +import asyncio +import struct +from importlib import import_module +from unittest.mock import AsyncMock + +import pytest + + +def get_valkey_module(): + """Lazy import of the valkey_search backend module.""" + return import_module('langbot.pkg.vector.vdbs.valkey_search') + + +def make_backend(): + """Construct a backend instance without running its __init__. + + The constructor needs a live ``ap`` + config; for pure-helper tests we + only need a bare instance with the attributes the helpers touch. + """ + mod = get_valkey_module() + backend = object.__new__(mod.ValkeySearchVectorDatabase) + # _ensure_client serializes creation through this lock; set it here since + # __init__ (which normally creates it) is bypassed. + backend._client_lock = asyncio.Lock() + return backend + + +class TestFloat32Packing: + """Tests for _pack_vector little-endian float32 packing.""" + + def test_pack_round_trips(self): + mod = get_valkey_module() + vec = [0.1, -2.5, 3.0, 4.25] + packed = mod.ValkeySearchVectorDatabase._pack_vector(vec) + assert isinstance(packed, bytes) + assert len(packed) == 4 * len(vec) + unpacked = list(struct.unpack(f'<{len(vec)}f', packed)) + for original, restored in zip(vec, unpacked): + assert restored == pytest.approx(original, rel=1e-6) + + def test_pack_is_little_endian(self): + mod = get_valkey_module() + packed = mod.ValkeySearchVectorDatabase._pack_vector([1.0]) + assert packed == struct.pack(' maps to no FT conditions. + deleted = await backend.delete_by_filter('col1', {'some_other_field': 'x'}) + + assert deleted == 0 + backend._client.delete.assert_not_called() + + async def test_supported_filter_deletes_matching_keys(self): + backend = make_backend() + backend._client = AsyncMock() + backend.ap = type('Ap', (), {'logger': AsyncMock()})() + backend._ensure_client = AsyncMock(return_value=backend._client) + backend._index_exists = AsyncMock(return_value=True) + backend._search_keys = AsyncMock(return_value=['kb:col1:id1', 'kb:col1:id2']) + + deleted = await backend.delete_by_filter('col1', {'file_id': 'f1'}) + + assert deleted == 2 + backend._client.delete.assert_awaited_once_with(['kb:col1:id1', 'kb:col1:id2']) + + +class TestClose: + """Tests for the close() teardown.""" + + async def test_close_resets_client_and_indexes(self): + backend = make_backend() + client = AsyncMock() + backend._client = client + backend.ap = type('Ap', (), {'logger': AsyncMock()})() + backend._ensured_indexes = {'idx:col1'} + + await backend.close() + + client.close.assert_awaited_once() + assert backend._client is None + assert backend._ensured_indexes == set() + + async def test_close_is_noop_when_no_client(self): + backend = make_backend() + backend._client = None + backend.ap = type('Ap', (), {'logger': AsyncMock()})() + backend._ensured_indexes = set() + # Should not raise. + await backend.close() + assert backend._client is None + + +class TestCredentialsBuild: + """Tests for the auth-credential construction in _ensure_client.""" + + def _prep_backend(self, mod, monkeypatch, *, username, password): + backend = make_backend() + backend._client = None + backend._host = 'localhost' + backend._port = 6379 + backend._db = 0 + backend._tls = False + backend._username = username + backend._password = password + backend._request_timeout = 5000 + backend._ensured_indexes = set() + warnings: list[str] = [] + backend.ap = type( + 'Ap', + (), + { + 'logger': type( + 'L', (), {'info': lambda self, *a, **k: None, 'warning': lambda s, m, *a, **k: warnings.append(m)} + )() + }, + )() + + created = {} + + class _FakeClient: + @staticmethod + async def create(conf): + created['conf'] = conf + return AsyncMock() + + cred_calls: list[dict] = [] + + def _fake_credentials(**kwargs): + cred_calls.append(kwargs) + return ('CRED', kwargs) + + monkeypatch.setattr(mod, 'GlideClient', _FakeClient) + monkeypatch.setattr(mod, 'ServerCredentials', _fake_credentials) + monkeypatch.setattr(mod, 'GlideClientConfiguration', lambda **kw: kw) + monkeypatch.setattr(mod, 'NodeAddress', lambda *a, **k: ('node', a, k)) + return backend, created, cred_calls, warnings + + async def test_username_without_password_fails_closed(self, monkeypatch): + mod = get_valkey_module() + backend, created, cred_calls, warnings = self._prep_backend(mod, monkeypatch, username='acluser', password=None) + + # A username without a password must fail closed rather than silently + # connecting unauthenticated to a (potentially shared) Valkey instance. + with pytest.raises(ValueError, match='without a password'): + await backend._ensure_client() + + assert cred_calls == [] # ServerCredentials NOT constructed + assert 'conf' not in created # client never created + + async def test_password_builds_credentials(self, monkeypatch): + mod = get_valkey_module() + backend, created, cred_calls, warnings = self._prep_backend( + mod, monkeypatch, username='acluser', password='secret' + ) + + await backend._ensure_client() + + assert len(cred_calls) == 1 + assert cred_calls[0] == {'password': 'secret', 'username': 'acluser'} + assert created['conf']['credentials'] == ('CRED', {'password': 'secret', 'username': 'acluser'}) diff --git a/tests/unit_tests/vector/test_vdb_base.py b/tests/unit_tests/vector/test_vdb_base.py index f67aec163..427df9f19 100644 --- a/tests/unit_tests/vector/test_vdb_base.py +++ b/tests/unit_tests/vector/test_vdb_base.py @@ -39,6 +39,7 @@ class TestVectorDatabaseAbstractMethods: def test_abstract_methods_required(self): """Subclass must implement all abstract methods.""" + class IncompleteVectorDB(VectorDatabase): pass @@ -47,11 +48,21 @@ class TestVectorDatabaseAbstractMethods: def test_supported_search_types_default(self): """Default supported_search_types returns [VECTOR].""" + class MinimalVectorDB(VectorDatabase): async def add_embeddings(self, collection, ids, embeddings_list, metadatas, documents=None): pass - async def search(self, collection, query_embedding, k=5, search_type='vector', query_text='', filter=None, vector_weight=None): + async def search( + self, + collection, + query_embedding, + k=5, + search_type='vector', + query_text='', + filter=None, + vector_weight=None, + ): pass async def delete_by_file_id(self, collection, file_id): @@ -71,11 +82,21 @@ class TestVectorDatabaseAbstractMethods: def test_list_by_filter_default_implementation(self): """list_by_filter has default implementation returning empty.""" + class MinimalVectorDB(VectorDatabase): async def add_embeddings(self, collection, ids, embeddings_list, metadatas, documents=None): pass - async def search(self, collection, query_embedding, k=5, search_type='vector', query_text='', filter=None, vector_weight=None): + async def search( + self, + collection, + query_embedding, + k=5, + search_type='vector', + query_text='', + filter=None, + vector_weight=None, + ): pass async def delete_by_file_id(self, collection, file_id): @@ -93,9 +114,8 @@ class TestVectorDatabaseAbstractMethods: db = MinimalVectorDB() # list_by_filter should return empty list and -1 for total import asyncio - result = asyncio.get_event_loop().run_until_complete( - db.list_by_filter('test_collection') - ) + + result = asyncio.get_event_loop().run_until_complete(db.list_by_filter('test_collection')) assert result == ([], -1) @@ -105,14 +125,17 @@ class TestVectorDatabaseInterface: @pytest.fixture def mock_vector_db(self): """Create a minimal mock VectorDatabase for testing.""" + class MockVectorDB(VectorDatabase): def __init__(self): self.add_embeddings = AsyncMock() - self.search = AsyncMock(return_value={ - 'ids': [['id1', 'id2']], - 'distances': [[0.1, 0.2]], - 'metadatas': [[{'key': 'val1'}, {'key': 'val2'}]] - }) + self.search = AsyncMock( + return_value={ + 'ids': [['id1', 'id2']], + 'distances': [[0.1, 0.2]], + 'metadatas': [[{'key': 'val1'}, {'key': 'val2'}]], + } + ) self.delete_by_file_id = AsyncMock() self.delete_by_filter = AsyncMock(return_value=5) self.get_or_create_collection = AsyncMock() @@ -121,7 +144,16 @@ class TestVectorDatabaseInterface: async def add_embeddings(self, collection, ids, embeddings_list, metadatas, documents=None): pass - async def search(self, collection, query_embedding, k=5, search_type='vector', query_text='', filter=None, vector_weight=None): + async def search( + self, + collection, + query_embedding, + k=5, + search_type='vector', + query_text='', + filter=None, + vector_weight=None, + ): pass async def delete_by_file_id(self, collection, file_id): @@ -146,7 +178,7 @@ class TestVectorDatabaseInterface: ids=['id1', 'id2'], embeddings_list=[[0.1, 0.2], [0.3, 0.4]], metadatas=[{'a': 1}, {'b': 2}], - documents=['doc1', 'doc2'] + documents=['doc1', 'doc2'], ) mock_vector_db.add_embeddings.assert_called_once() @@ -162,7 +194,7 @@ class TestVectorDatabaseInterface: search_type='hybrid', query_text='search text', filter={'file_id': 'abc'}, - vector_weight=0.7 + vector_weight=0.7, ) mock_vector_db.search.assert_called_once() @@ -170,4 +202,4 @@ class TestVectorDatabaseInterface: async def test_delete_by_filter_returns_int(self, mock_vector_db): """delete_by_filter returns int count.""" result = await mock_vector_db.delete_by_filter('test', {'file_id': 'abc'}) - assert isinstance(result, int) \ No newline at end of file + assert isinstance(result, int) diff --git a/tests/unit_tests/vector/test_vdb_filter_conversion.py b/tests/unit_tests/vector/test_vdb_filter_conversion.py index 5499b9087..cc79f62a4 100644 --- a/tests/unit_tests/vector/test_vdb_filter_conversion.py +++ b/tests/unit_tests/vector/test_vdb_filter_conversion.py @@ -5,6 +5,7 @@ Tests cover: - _build_milvus_expr: Milvus boolean expression string conversion - _build_pg_conditions: PostgreSQL SQLAlchemy conditions conversion """ + from __future__ import annotations from importlib import import_module @@ -122,11 +123,13 @@ class TestQdrantFilterConversion: """Multiple conditions are combined in must/must_not.""" qdrant_module = get_qdrant_module() - result = qdrant_module._build_qdrant_filter({ - 'file_id': 'abc', - 'status': {'$ne': 'deleted'}, - 'created_at': {'$gte': 100}, - }) + result = qdrant_module._build_qdrant_filter( + { + 'file_id': 'abc', + 'status': {'$ne': 'deleted'}, + 'created_at': {'$gte': 100}, + } + ) assert len(result.must) == 2 # file_id eq + created_at gte assert len(result.must_not) == 1 # status ne @@ -198,10 +201,12 @@ class TestMilvusFilterConversion: """Multiple conditions are joined with 'and'.""" milvus_module = get_milvus_module() - result = milvus_module._build_milvus_expr({ - 'file_id': 'abc', - 'chunk_uuid': {'$ne': 'def'}, - }) + result = milvus_module._build_milvus_expr( + { + 'file_id': 'abc', + 'chunk_uuid': {'$ne': 'def'}, + } + ) assert 'and' in result assert 'file_id == "abc"' in result assert 'chunk_uuid != "def"' in result @@ -272,6 +277,7 @@ class TestPgVectorFilterConversion: assert len(result) == 1 # Verify it's a SQLAlchemy BinaryExpression from sqlalchemy.sql.expression import BinaryExpression + assert isinstance(result[0], BinaryExpression) def test_ne_operator_creates_inequality_condition(self): @@ -321,10 +327,12 @@ class TestPgVectorFilterConversion: """Multiple conditions return list of conditions.""" pgvector_module = get_pgvector_module() - result = pgvector_module._build_pg_conditions({ - 'file_id': 'abc', - 'chunk_uuid': {'$ne': 'def'}, - }) + result = pgvector_module._build_pg_conditions( + { + 'file_id': 'abc', + 'chunk_uuid': {'$ne': 'def'}, + } + ) assert len(result) == 2 @@ -349,11 +357,13 @@ class TestPgVectorFilterConversion: """Only supported fields (text, file_id, chunk_uuid) are kept.""" pgvector_module = get_pgvector_module() - result = pgvector_module._build_pg_conditions({ - 'text': {'$ne': ''}, - 'file_id': 'abc', - 'chunk_uuid': {'$in': ['x', 'y']}, - 'unsupported': 'value', - }) + result = pgvector_module._build_pg_conditions( + { + 'text': {'$ne': ''}, + 'file_id': 'abc', + 'chunk_uuid': {'$in': ['x', 'y']}, + 'unsupported': 'value', + } + ) - assert len(result) == 3 # Only supported fields \ No newline at end of file + assert len(result) == 3 # Only supported fields diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index a8ead047e..11b530011 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -1,3 +1,3 @@ """ Test utilities package. -""" \ No newline at end of file +""" diff --git a/tests/utils/import_isolation.py b/tests/utils/import_isolation.py index 7d4487a8f..9f2b3c583 100644 --- a/tests/utils/import_isolation.py +++ b/tests/utils/import_isolation.py @@ -26,6 +26,7 @@ from unittest.mock import MagicMock class MockLifecycleControlScope(enum.Enum): """Mock enum for breaking circular import in core.entities.""" + APPLICATION = 'application' PLATFORM = 'platform' PLUGIN = 'plugin' @@ -190,4 +191,4 @@ def get_handler_modules_to_clear(handler_name: str) -> list[str]: 'langbot.pkg.pipeline.process.handler', 'langbot.pkg.pipeline.process.handlers', f'langbot.pkg.pipeline.process.handlers.{handler_name}', - ] \ No newline at end of file + ] diff --git a/uv.lock b/uv.lock index fc56bbbc0..3e4b2a124 100644 --- a/uv.lock +++ b/uv.lock @@ -55,7 +55,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -64,95 +64,111 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, - { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, - { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, - { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, - { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, - { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, - { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, - { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, - { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, - { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, - { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, - { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, - { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, - { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, - { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, - { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, - { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, - { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, - { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, - { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, - { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, - { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, - { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, - { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, - { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, - { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, - { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, - { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, - { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, - { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, - { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, - { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, - { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, - { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, - { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, - { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, - { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, ] [[package]] @@ -560,6 +576,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/71/9a2c88abb5fe47b46168b262254d5b5d635de371eba4bd01ea5c8c109575/botocore-1.42.39-py3-none-any.whl", hash = "sha256:9e0d0fed9226449cc26fcf2bbffc0392ac698dd8378e8395ce54f3ec13f81d58", size = 14591958, upload-time = "2026-01-30T20:38:14.814Z" }, ] +[[package]] +name = "bracex" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, +] + [[package]] name = "build" version = "1.4.0" @@ -934,85 +959,126 @@ toml = [ [[package]] name = "cryptography" -version = "47.0.0" +version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, - { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, - { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, - { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, - { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, - { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, - { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, - { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, - { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, - { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, - { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, - { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, - { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, - { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, - { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, - { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, - { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, - { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, - { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, - { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, - { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, - { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, - { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, - { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, - { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] [[package]] name = "cuda-bindings" -version = "12.9.4" +version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/45/e7/b47792cc2d01c7e1d37c32402182524774dadd2d26339bd224e0e913832e/cuda_bindings-12.9.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c912a3d9e6b6651853eed8eed96d6800d69c08e94052c292fec3f282c5a817c9", size = 12210593, upload-time = "2025-10-21T14:51:36.574Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fda147a344e8eaeca0c6ff113d2851ffca8f7dfc0a6c932374ee5c47caa649c8", size = 12151019, upload-time = "2025-10-21T14:51:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/56/e465c31dc9111be3441a9ba7df1941fe98f4aa6e71e8788a3fb4534ce24d/cuda_bindings-12.9.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:32bdc5a76906be4c61eb98f546a6786c5773a881f3b166486449b5d141e4a39f", size = 11906628, upload-time = "2025-10-21T14:51:49.905Z" }, - { url = "https://files.pythonhosted.org/packages/a3/84/1e6be415e37478070aeeee5884c2022713c1ecc735e6d82d744de0252eee/cuda_bindings-12.9.4-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56e0043c457a99ac473ddc926fe0dc4046694d99caef633e92601ab52cbe17eb", size = 11925991, upload-time = "2025-10-21T14:51:56.535Z" }, - { url = "https://files.pythonhosted.org/packages/d1/af/6dfd8f2ed90b1d4719bc053ff8940e494640fe4212dc3dd72f383e4992da/cuda_bindings-12.9.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b72ee72a9cc1b531db31eebaaee5c69a8ec3500e32c6933f2d3b15297b53686", size = 11922703, upload-time = "2025-10-21T14:52:03.585Z" }, - { url = "https://files.pythonhosted.org/packages/6c/19/90ac264acc00f6df8a49378eedec9fd2db3061bf9263bf9f39fd3d8377c3/cuda_bindings-12.9.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d80bffc357df9988dca279734bc9674c3934a654cab10cadeed27ce17d8635ee", size = 11924658, upload-time = "2025-10-21T14:52:10.411Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/95/7a/c5e3c34a409b148f5c0f5a4ea374158f95d488862c1dffedf9aa5c639df9/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708", size = 6674166, upload-time = "2026-05-29T23:11:45.478Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965, upload-time = "2026-05-29T23:11:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/2394f8163360f8391f8f1b7e72d300a82724edb81a7b7084c799fbd4c91f/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf", size = 5920504, upload-time = "2026-05-29T23:11:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/ef9b6a63f7dc432712a462c816662e662e00d38caa9b861c8c2588195d03/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7", size = 6476660, upload-time = "2026-05-29T23:11:59.188Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/bff68ce829999c1e4209c761bbf903b1c06ec570416ddb25020864ad5907/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8", size = 6013639, upload-time = "2026-05-29T23:12:03.509Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e0/c8a1f0c8f9ffdea4f5fe6dbab89b326cef4d85caf489dad39e209da89416/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80", size = 6534419, upload-time = "2026-05-29T23:12:05.633Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/83b1f563925b290f2d11a01a77a84013ba56052fe3653a5bef3ccfbb43d6/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76", size = 5809771, upload-time = "2026-05-29T23:12:10.422Z" }, + { url = "https://files.pythonhosted.org/packages/12/20/e79b4bfe98f075195afb6343d41c498f9dbd2d161d7021d4d28bceb83581/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9", size = 6358584, upload-time = "2026-05-29T23:12:12.767Z" }, ] [[package]] name = "cuda-pathfinder" -version = "1.4.1" +version = "1.5.5" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/02/59a5bc738a09def0b49aea0e460bdf97f65206d0d041246147cf6207e69c/cuda_pathfinder-1.4.1-py3-none-any.whl", hash = "sha256:40793006082de88e0950753655e55558a446bed9a7d9d0bcb48b2506d50ed82a", size = 43903, upload-time = "2026-03-06T21:05:24.372Z" }, + { url = "https://files.pythonhosted.org/packages/11/c8/26f2e4aae92f11522a96043892ba39a90eac610d5242523aa863212bc1c7/cuda_pathfinder-1.5.5-py3-none-any.whl", hash = "sha256:0228c023f95d1480f143ef5c8922d27a2ab052087a942e81dc289c9eb8f91689", size = 51671, upload-time = "2026-05-27T01:21:25.413Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, +] + +[package.optional-dependencies] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, +] +curand = [ + { name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, +] +cusolver = [ + { name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] [[package]] @@ -1086,6 +1152,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dockerfile-parse" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc", size = 24556, upload-time = "2023-07-18T13:36:07.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6", size = 14845, upload-time = "2023-07-18T13:36:06.052Z" }, +] + [[package]] name = "docstring-parser" version = "0.17.0" @@ -1115,6 +1190,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, ] +[[package]] +name = "e2b" +version = "2.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "dockerfile-parse" }, + { name = "h2" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "python-dateutil" }, + { name = "rich" }, + { name = "typing-extensions" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/97/0e86ccb9e05c18e6e795e0808f14e2dc9f5c9ffb7be2a5cb77afd6d9f59e/e2b-2.21.1.tar.gz", hash = "sha256:2eff473ca03173cee1ccd9f9ec9e90c2b4705cca418e080e3104753d7ec33490", size = 157458, upload-time = "2026-05-14T17:36:02.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/d4/8b6a9a120e724dd8f91aededa89348a667a01fffbba28ae1a42cb397b0f0/e2b-2.21.1-py3-none-any.whl", hash = "sha256:9ec4646f3dba4a6da855baa8adeab239aa988e15904611e38bf12aeec2562ac9", size = 297476, upload-time = "2026-05-14T17:36:00.351Z" }, +] + [[package]] name = "ebooklib" version = "0.20" @@ -1128,6 +1225,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/ee/aa015c5de8b0dc42a8e507eae8c2de5d1c0e068c896858fec6d502402ed6/ebooklib-0.20-py3-none-any.whl", hash = "sha256:fff5322517a37e31c972d27be7d982cc3928c16b3dcc5fd7e8f7c0f5d7bcf42b", size = 40995, upload-time = "2025-10-26T20:56:19.104Z" }, ] +[[package]] +name = "fastuuid" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/f3/12481bda4e5b6d3e698fbf525df4443cc7dce746f246b86b6fcb2fba1844/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34", size = 516386, upload-time = "2025-10-19T22:42:40.176Z" }, + { url = "https://files.pythonhosted.org/packages/59/19/2fc58a1446e4d72b655648eb0879b04e88ed6fa70d474efcf550f640f6ec/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7", size = 264569, upload-time = "2025-10-19T22:25:50.977Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/3c74756e5b02c40cfcc8b1d8b5bac4edbd532b55917a6bcc9113550e99d1/fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1", size = 254366, upload-time = "2025-10-19T22:29:49.166Z" }, + { url = "https://files.pythonhosted.org/packages/52/96/d761da3fccfa84f0f353ce6e3eb8b7f76b3aa21fd25e1b00a19f9c80a063/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc", size = 278978, upload-time = "2025-10-19T22:35:41.306Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/f84c90167cc7765cb82b3ff7808057608b21c14a38531845d933a4637307/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8", size = 279692, upload-time = "2025-10-19T22:25:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/af/7b/4bacd03897b88c12348e7bd77943bac32ccf80ff98100598fcff74f75f2e/fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7", size = 303384, upload-time = "2025-10-19T22:29:46.578Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a2/584f2c29641df8bd810d00c1f21d408c12e9ad0c0dafdb8b7b29e5ddf787/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73", size = 460921, upload-time = "2025-10-19T22:36:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/c6b77443bb7764c760e211002c8638c0c7cce11cb584927e723215ba1398/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36", size = 480575, upload-time = "2025-10-19T22:28:18.975Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/93f553111b33f9bb83145be12868c3c475bf8ea87c107063d01377cc0e8e/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94", size = 452317, upload-time = "2025-10-19T22:25:32.75Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8c/a04d486ca55b5abb7eaa65b39df8d891b7b1635b22db2163734dc273579a/fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24", size = 154804, upload-time = "2025-10-19T22:24:15.615Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b2/2d40bf00820de94b9280366a122cbaa60090c8cf59e89ac3938cf5d75895/fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa", size = 156099, upload-time = "2025-10-19T22:24:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164, upload-time = "2025-10-19T22:31:45.635Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837, upload-time = "2025-10-19T22:38:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370, upload-time = "2025-10-19T22:40:26.07Z" }, + { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766, upload-time = "2025-10-19T22:37:23.779Z" }, + { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105, upload-time = "2025-10-19T22:26:56.821Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564, upload-time = "2025-10-19T22:30:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659, upload-time = "2025-10-19T22:31:32.341Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430, upload-time = "2025-10-19T22:26:22.962Z" }, + { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894, upload-time = "2025-10-19T22:27:01.647Z" }, + { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374, upload-time = "2025-10-19T22:29:19.879Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550, upload-time = "2025-10-19T22:27:49.658Z" }, + { url = "https://files.pythonhosted.org/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021", size = 510720, upload-time = "2025-10-19T22:42:34.633Z" }, + { url = "https://files.pythonhosted.org/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc", size = 262024, upload-time = "2025-10-19T22:30:25.482Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5", size = 251679, upload-time = "2025-10-19T22:36:14.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f", size = 277862, upload-time = "2025-10-19T22:36:23.302Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87", size = 278278, upload-time = "2025-10-19T22:29:43.809Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b", size = 301788, upload-time = "2025-10-19T22:36:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022", size = 459819, upload-time = "2025-10-19T22:35:31.623Z" }, + { url = "https://files.pythonhosted.org/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995", size = 478546, upload-time = "2025-10-19T22:26:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab", size = 450921, upload-time = "2025-10-19T22:31:02.151Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad", size = 154559, upload-time = "2025-10-19T22:36:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed", size = 156539, upload-time = "2025-10-19T22:33:35.898Z" }, + { url = "https://files.pythonhosted.org/packages/16/c9/8c7660d1fe3862e3f8acabd9be7fc9ad71eb270f1c65cce9a2b7a31329ab/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad", size = 510600, upload-time = "2025-10-19T22:43:44.17Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f4/a989c82f9a90d0ad995aa957b3e572ebef163c5299823b4027986f133dfb/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b", size = 262069, upload-time = "2025-10-19T22:43:38.38Z" }, + { url = "https://files.pythonhosted.org/packages/da/6c/a1a24f73574ac995482b1326cf7ab41301af0fabaa3e37eeb6b3df00e6e2/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714", size = 251543, upload-time = "2025-10-19T22:32:22.537Z" }, + { url = "https://files.pythonhosted.org/packages/1a/20/2a9b59185ba7a6c7b37808431477c2d739fcbdabbf63e00243e37bd6bf49/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f", size = 277798, upload-time = "2025-10-19T22:33:53.821Z" }, + { url = "https://files.pythonhosted.org/packages/ef/33/4105ca574f6ded0af6a797d39add041bcfb468a1255fbbe82fcb6f592da2/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f", size = 278283, upload-time = "2025-10-19T22:29:02.812Z" }, + { url = "https://files.pythonhosted.org/packages/fe/8c/fca59f8e21c4deb013f574eae05723737ddb1d2937ce87cb2a5d20992dc3/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75", size = 301627, upload-time = "2025-10-19T22:35:54.985Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e2/f78c271b909c034d429218f2798ca4e89eeda7983f4257d7865976ddbb6c/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4", size = 459778, upload-time = "2025-10-19T22:28:00.999Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f0/5ff209d865897667a2ff3e7a572267a9ced8f7313919f6d6043aed8b1caa/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad", size = 478605, upload-time = "2025-10-19T22:36:21.764Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c8/2ce1c78f983a2c4987ea865d9516dbdfb141a120fd3abb977ae6f02ba7ca/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8", size = 450837, upload-time = "2025-10-19T22:34:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/df/60/dad662ec9a33b4a5fe44f60699258da64172c39bd041da2994422cdc40fe/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06", size = 154532, upload-time = "2025-10-19T22:35:18.217Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a", size = 156457, upload-time = "2025-10-19T22:33:44.579Z" }, +] + [[package]] name = "filelock" version = "3.20.3" @@ -1628,11 +1777,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -1859,7 +2008,7 @@ wheels = [ [[package]] name = "langbot" -version = "4.9.7" +version = "4.10.5" source = { editable = "." } dependencies = [ { name = "aiocqhttp" }, @@ -1893,6 +2042,7 @@ dependencies = [ { name = "langsmith" }, { name = "lark-oapi" }, { name = "line-bot-sdk" }, + { name = "litellm" }, { name = "mako" }, { name = "markdown" }, { name = "matrix-nio" }, @@ -1934,6 +2084,7 @@ dependencies = [ { name = "tiktoken" }, { name = "urllib3" }, { name = "uv" }, + { name = "valkey-glide" }, { name = "websockets" }, ] @@ -1951,7 +2102,7 @@ dev = [ requires-dist = [ { name = "aiocqhttp", specifier = ">=1.4.4" }, { name = "aiofiles", specifier = ">=24.1.0" }, - { name = "aiohttp", specifier = ">=3.13.4" }, + { name = "aiohttp", specifier = ">=3.14.1" }, { name = "aioshutil", specifier = ">=1.5" }, { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "alembic", specifier = ">=1.15.0" }, @@ -1966,21 +2117,22 @@ requires-dist = [ { name = "chardet", specifier = ">=5.2.0" }, { name = "chromadb", specifier = ">=1.0.0,<2.0.0" }, { name = "colorlog", specifier = "~=6.6.0" }, - { name = "cryptography", specifier = ">=46.0.7" }, + { name = "cryptography", specifier = ">=48.0.1" }, { name = "dashscope", specifier = ">=1.25.10" }, { name = "dingtalk-stream", specifier = ">=0.24.0" }, { name = "discord-py", specifier = ">=2.5.2" }, { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", specifier = "==0.3.11" }, - { name = "langchain", specifier = ">=0.2.0" }, - { name = "langchain-core", specifier = ">=1.2.28" }, + { name = "langbot-plugin", specifier = "==0.4.13" }, + { name = "langchain", specifier = ">=1.3.9" }, + { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, - { name = "langsmith", specifier = ">=0.7.31" }, + { name = "langsmith", specifier = ">=0.8.18" }, { name = "lark-oapi", specifier = ">=1.5.5" }, { name = "line-bot-sdk", specifier = ">=3.19.0" }, - { name = "mako", specifier = ">=1.3.11" }, + { name = "litellm", specifier = ">=1.0.0" }, + { name = "mako", specifier = ">=1.3.12" }, { name = "markdown", specifier = ">=3.6" }, { name = "matrix-nio", specifier = ">=0.25.2" }, { name = "mcp", specifier = ">=1.25.0" }, @@ -1991,18 +2143,18 @@ requires-dist = [ { name = "pandas", specifier = ">=2.2.2" }, { name = "pgvector", specifier = ">=0.4.1" }, { name = "pillow", specifier = ">=12.2.0" }, - { name = "pip", specifier = ">=25.1.1" }, + { name = "pip", specifier = ">=26.1" }, { name = "pre-commit", specifier = ">=4.2.0" }, { name = "psutil", specifier = ">=7.0.0" }, { name = "pycryptodome", specifier = ">=3.22.0" }, { name = "pydantic", specifier = ">2.0" }, - { name = "pyjwt", specifier = ">=2.10.1" }, + { name = "pyjwt", specifier = ">=2.12.0" }, { name = "pymilvus", specifier = ">=2.6.4" }, { name = "pynacl", specifier = ">=1.5.0" }, { name = "pypdf2", specifier = ">=3.0.1" }, { name = "pyseekdb", specifier = "==1.1.0.post3" }, { name = "python-docx", specifier = ">=1.1.0" }, - { name = "python-multipart", specifier = ">=0.0.26" }, + { name = "python-multipart", specifier = ">=0.0.27" }, { name = "python-socks", specifier = ">=2.7.1" }, { name = "python-telegram-bot", specifier = ">=22.0" }, { name = "pyyaml", specifier = ">=6.0.2" }, @@ -2011,7 +2163,7 @@ requires-dist = [ { name = "qrcode", specifier = ">=7.4" }, { name = "quart", specifier = ">=0.20.0" }, { name = "quart-cors", specifier = ">=0.8.0" }, - { name = "requests", specifier = ">=2.32.3" }, + { name = "requests", specifier = ">=2.33.0" }, { name = "ruff", specifier = ">=0.11.9" }, { name = "slack-sdk", specifier = ">=3.35.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.40" }, @@ -2019,8 +2171,9 @@ requires-dist = [ { name = "tboxsdk", specifier = ">=0.0.10" }, { name = "telegramify-markdown", specifier = ">=0.5.1" }, { name = "tiktoken", specifier = ">=0.9.0" }, - { name = "urllib3", specifier = ">=2.4.0" }, - { name = "uv", specifier = ">=0.11.6" }, + { name = "urllib3", specifier = ">=2.7.0" }, + { name = "uv", specifier = ">=0.11.15" }, + { name = "valkey-glide", specifier = ">=2.4.1,<3.0.0" }, { name = "websockets", specifier = ">=15.0.1" }, ] @@ -2036,13 +2189,16 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.3.11" +version = "0.4.13" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, + { name = "aiohttp" }, { name = "dotenv" }, + { name = "e2b" }, { name = "httpx" }, { name = "jinja2" }, + { name = "packaging" }, { name = "pip" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -2054,28 +2210,28 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/83/93b86bcdbfe51d820fa59232aaa73cc802d6ce614f67d8f8b33957419538/langbot_plugin-0.3.11.tar.gz", hash = "sha256:8d10c98c771b468b2d35cc007778439c39922a88265fcc16a5881234bc7c1b19", size = 190315, upload-time = "2026-05-12T15:45:24.262Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/a6/1eaf77c3b81e9de3390c504c5f627dc41f43bff6df9aff0e1e31d796b6f0/langbot_plugin-0.4.13.tar.gz", hash = "sha256:f936340e67679c21f1e7e7f1447339f31a0a2c965db060ecfbd9d0c51bb0d6fe", size = 334887, upload-time = "2026-07-04T05:38:59.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/22/de7977a6a5cbf557b80043eb3ed39e5feff24033a5d6db4ab88d48ccb6ea/langbot_plugin-0.3.11-py3-none-any.whl", hash = "sha256:c1d2e84eda1584902d99efa316b850c08c1c04fcc199306ff4af1dca1431304a", size = 165574, upload-time = "2026-05-12T15:45:22.908Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bf/fc9671a7afbd933440c38403c84d918c1022fdeed16e22a6ab3b2aec83ff/langbot_plugin-0.4.13-py3-none-any.whl", hash = "sha256:9d45ebc7a7ee0413d6db9baa009fcbf0ad07e2e1753a6f0a27f37b8b665cd1ee", size = 221884, upload-time = "2026-07-04T05:38:58.525Z" }, ] [[package]] name = "langchain" -version = "1.2.12" +version = "1.3.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/1d/1af2fc0ac084d4781778b7846b1aed62e05006bf2d73fdf84ac3a8f5225c/langchain-1.2.12.tar.gz", hash = "sha256:ed705b5b293799f7e3e394387f398a1b71707542758283206c8c21415759d991", size = 566444, upload-time = "2026-03-11T22:21:00.712Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/e351d85c7828b9b90c5729de66170457c882c754efef0712904cfcd3192d/langchain-1.3.10.tar.gz", hash = "sha256:fd6ac9da86c479e4ff376e772d9e17a9232bd3113e9f2ddcb70cdc4bf7afc119", size = 632522, upload-time = "2026-06-18T19:43:00.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/51/09bb1cfb0b57ae9440ca56cc576e4dc792f83d030eef7637d2c516dcb0a0/langchain-1.2.12-py3-none-any.whl", hash = "sha256:60eff184b8f92c2610f5a4c9a97ad339a891adb01901e83e4df8e6c9c69cf852", size = 112373, upload-time = "2026-03-11T22:20:59.508Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/a682e68d004a2e23cae6c5c42e3c0d071bc0e7768167bd12277992f096f9/langchain-1.3.10-py3-none-any.whl", hash = "sha256:5da67f21aa56119744ad51b3e46ffac570c88f4fae0876e3b1c6a1c4bc0e344e", size = 133038, upload-time = "2026-06-18T19:42:58.918Z" }, ] [[package]] name = "langchain-core" -version = "1.3.2" +version = "1.4.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -2088,21 +2244,21 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/03/7219502e8ca728d65eb44d7a3eb60239230742a70dbfc9241b9bfd61c4ab/langchain_core-1.3.2.tar.gz", hash = "sha256:fd7a50b2f28ba561fd9d7f5d2760bc9e06cf00cdf820a3ccafe88a94ffa8d5b7", size = 911813, upload-time = "2026-04-24T15:49:23.699Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl", hash = "sha256:d44a66127f9f8db735bdfd0ab9661bccb47a97113cfd3f2d89c74864422b7274", size = 542390, upload-time = "2026-04-24T15:49:21.991Z" }, + { url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" }, ] [[package]] name = "langchain-protocol" -version = "0.0.12" +version = "0.0.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/51/1157009b6f94e6e58be58fa8b620187d657909a8b36a6bf5b0c52a2711f6/langchain_protocol-0.0.12.tar.gz", hash = "sha256:5e14c434290a705c9510fdb1a83ecf7561a5e6e0dfd053930ade80dba069269f", size = 6408, upload-time = "2026-04-25T01:05:01.489Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/82/3431e3061c917439589fa88a6b23c9bc0e154cba0f05d2e895a68c76ff74/langchain_protocol-0.0.12-py3-none-any.whl", hash = "sha256:402b61f42d4139692528cf37226c367bb6efc8ff8165b29380accb0abfece7b2", size = 6639, upload-time = "2026-04-25T01:05:00.487Z" }, + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, ] [[package]] @@ -2119,7 +2275,7 @@ wheels = [ [[package]] name = "langgraph" -version = "1.1.1" +version = "1.2.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, @@ -2129,53 +2285,56 @@ dependencies = [ { name = "pydantic" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/1a/6dbad0c87fb39a58e5ced85297511cc4bcad06cc420b20898eecafece2a2/langgraph-1.1.1.tar.gz", hash = "sha256:cd6282efc657c955b41bff6bd9693de58137ad18f7e7f16b4d17c7d2118d53e1", size = 544040, upload-time = "2026-03-11T22:14:47.845Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/7a/ea09b05bb0cbddfa43bd34fc581357e87fc3f21a751cc0d419688c3106da/langgraph-1.2.6.tar.gz", hash = "sha256:f9b45a34f13930c94d96cdb76277447ad2cc70ec2d18cd2764d7fdadb36cdc1b", size = 714400, upload-time = "2026-06-18T20:58:21.514Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/c1/572187bb61a534050ef2d5030e7abe46b19694ec106604fe12ddcb8672c7/langgraph-1.1.1-py3-none-any.whl", hash = "sha256:d0cc8d347131cbfc010e65aad9b0f1afbd0e151f470c288bec1f3df8336c50c6", size = 167502, upload-time = "2026-03-11T22:14:46.121Z" }, + { url = "https://files.pythonhosted.org/packages/89/32/772db1b00a9fe42f50320d1aa20caefb76e621eff1f7218b9918093d631d/langgraph-1.2.6-py3-none-any.whl", hash = "sha256:1cf94d3ca124f84f77ce408fa1b06c3dee680a8aafffe364a8fd5d7d03eb8695", size = 246132, upload-time = "2026-06-18T20:58:20.335Z" }, ] [[package]] name = "langgraph-checkpoint" -version = "4.0.0" +version = "4.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "ormsgpack" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/76/55a18c59dedf39688d72c4b06af73a5e3ea0d1a01bc867b88fbf0659f203/langgraph_checkpoint-4.0.0.tar.gz", hash = "sha256:814d1bd050fac029476558d8e68d87bce9009a0262d04a2c14b918255954a624", size = 137320, upload-time = "2026-01-12T20:30:26.38Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/de/ddd53b7032e623f3c7bcdab2b44e8bf635e468f62e10e5ff1946f62c9356/langgraph_checkpoint-4.0.0-py3-none-any.whl", hash = "sha256:3fa9b2635a7c5ac28b338f631abf6a030c3b508b7b9ce17c22611513b589c784", size = 46329, upload-time = "2026-01-12T20:30:25.2Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" }, ] [[package]] name = "langgraph-prebuilt" -version = "1.0.8" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph-checkpoint" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/06/dd61a5c2dce009d1b03b1d56f2a85b3127659fdddf5b3be5d8f1d60820fb/langgraph_prebuilt-1.0.8.tar.gz", hash = "sha256:0cd3cf5473ced8a6cd687cc5294e08d3de57529d8dd14fdc6ae4899549efcf69", size = 164442, upload-time = "2026-02-19T18:14:39.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/41/ec966424ad3f2ed3996d24079d3342c8cd6c0bd0653c12b2a917a685ec6c/langgraph_prebuilt-1.0.8-py3-none-any.whl", hash = "sha256:d16a731e591ba4470f3e313a319c7eee7dbc40895bcf15c821f985a3522a7ce0", size = 35648, upload-time = "2026-02-19T18:14:37.611Z" }, + { url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" }, ] [[package]] name = "langgraph-sdk" -version = "0.3.3" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-protocol" }, { name = "orjson" }, + { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/0f/ed0634c222eed48a31ba48eab6881f94ad690d65e44fe7ca838240a260c1/langgraph_sdk-0.3.3.tar.gz", hash = "sha256:c34c3dce3b6848755eb61f0c94369d1ba04aceeb1b76015db1ea7362c544fb26", size = 130589, upload-time = "2026-01-13T00:30:43.894Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/be/4ad511bacfdd854afb12974f407cb30010dceb982dc20c55491867b34526/langgraph_sdk-0.3.3-py3-none-any.whl", hash = "sha256:a52ebaf09d91143e55378bb2d0b033ed98f57f48c9ad35c8f81493b88705fc7b", size = 67021, upload-time = "2026-01-13T00:30:42.264Z" }, + { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, ] [[package]] name = "langsmith" -version = "0.7.36" +version = "0.8.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2185,12 +2344,13 @@ dependencies = [ { name = "requests" }, { name = "requests-toolbelt" }, { name = "uuid-utils" }, + { name = "websockets" }, { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/4c/5f20508000ee0559bfa713b85c431b1cdc95d2913247ff9eb318e7fdff7b/langsmith-0.7.36.tar.gz", hash = "sha256:d18ef34819e0a252cf52c74ce6e9bd5de6deea4f85a3aef50abc9f48d8c5f8b8", size = 4402322, upload-time = "2026-04-24T16:58:06.681Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/8d/3ca31ae3a4a437191243ad6d9061ede9367440bb7dc9a0da1ecc2c2a4865/langsmith-0.7.36-py3-none-any.whl", hash = "sha256:e1657a795f3f1982bb8d34c98b143b630ca3eee9de2c10e670c9105233b54654", size = 381808, upload-time = "2026-04-24T16:58:04.572Z" }, + { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" }, ] [[package]] @@ -2303,6 +2463,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, ] +[[package]] +name = "litellm" +version = "1.88.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ea/f99ececb7f22703fe120f1d8be9ffb749ec9453fbbbbbebc0d6a6b4d7864/litellm-1.88.1.tar.gz", hash = "sha256:89c6b74cc7912d6365793006ff951c0450fe847625008dfe49de8a7dc4529aa5", size = 13885969, upload-time = "2026-06-09T01:06:25.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/9a/8f8909201b4bebaf96498c09226f6baa8540086a4c4188ad57d7dfbd97c1/litellm-1.88.1-py3-none-any.whl", hash = "sha256:369b84e57d9426582ddc35e731956ddb6618cda97cc44e4e4d2dfa75982a6e3a", size = 15276206, upload-time = "2026-06-09T01:06:16.72Z" }, +] + [[package]] name = "logbook" version = "1.9.2" @@ -2366,116 +2549,116 @@ wheels = [ [[package]] name = "lxml" -version = "6.0.2" +version = "6.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, - { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, - { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, - { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, - { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, - { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, - { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, - { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, - { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, - { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, - { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, - { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, - { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, - { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, - { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, - { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, - { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, - { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, - { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, - { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, - { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, - { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, - { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, - { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, - { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, - { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, - { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, - { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, - { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, - { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, - { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, - { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, - { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, - { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, - { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, - { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, - { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, - { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, - { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, - { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, - { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, + { url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" }, + { url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921, upload-time = "2026-05-18T19:17:49.175Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456, upload-time = "2026-05-18T19:17:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776, upload-time = "2026-05-18T19:18:08.924Z" }, + { url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945, upload-time = "2026-05-18T19:18:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237, upload-time = "2026-05-18T19:18:18.657Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904, upload-time = "2026-05-18T19:18:24.883Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225, upload-time = "2026-05-18T19:17:20.073Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721, upload-time = "2026-05-18T19:17:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549, upload-time = "2026-05-18T19:17:51.236Z" }, + { url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877, upload-time = "2026-05-18T19:18:00.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072, upload-time = "2026-05-18T19:17:12.714Z" }, + { url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469, upload-time = "2026-05-18T19:17:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640, upload-time = "2026-05-19T19:22:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, + { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" }, + { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" }, + { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" }, + { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, + { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, + { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, + { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, + { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, + { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127, upload-time = "2026-05-18T19:19:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769, upload-time = "2026-05-18T19:20:41.427Z" }, + { url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163, upload-time = "2026-05-18T19:20:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945, upload-time = "2026-05-18T19:20:47.385Z" }, + { url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664, upload-time = "2026-05-18T19:20:50.489Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" }, ] [[package]] name = "mako" -version = "1.3.11" +version = "1.3.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/8a/805404d0c0b9f3d7a326475ca008db57aea9c5c9f2e1e39ed0faa335571c/mako-1.3.11.tar.gz", hash = "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069", size = 399811, upload-time = "2026-04-14T20:19:51.493Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/a5/19d7aaa7e433713ffe881df33705925a196afb9532efc8475d26593921a6/mako-1.3.11-py3-none-any.whl", hash = "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77", size = 78503, upload-time = "2026-04-14T20:19:53.233Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, ] [[package]] @@ -2751,7 +2934,7 @@ wheels = [ [[package]] name = "moto" version = "5.2.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boto3" }, { name = "botocore" }, @@ -2761,9 +2944,9 @@ dependencies = [ { name = "werkzeug" }, { name = "xmltodict" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/e9/c38202162db2e76623176be9f1dbc9aa41228ffa91ee8da2d3986082c3e3/moto-5.2.1.tar.gz", hash = "sha256:ccb2f3e1dfa82e50e054bda98b0be708d244d2668364dcc1d45e8d3de6091bde", size = 8634437, upload-time = "2026-05-10T19:11:57.286Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/e9/c38202162db2e76623176be9f1dbc9aa41228ffa91ee8da2d3986082c3e3/moto-5.2.1.tar.gz", hash = "sha256:ccb2f3e1dfa82e50e054bda98b0be708d244d2668364dcc1d45e8d3de6091bde", size = 8634437, upload-time = "2026-05-10T19:11:57.286Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/79/8085b7c1ecd48d0535c3c8444a1d8df2926e457dce8e55fabc332a382c9c/moto-5.2.1-py3-none-any.whl", hash = "sha256:19d2fbd6e613aa5b4e364c52cd5d3cea371643a0f4210689a703227bd2924c5c", size = 6671379, upload-time = "2026-05-10T19:11:53.543Z" }, + { url = "https://files.pythonhosted.org/packages/15/79/8085b7c1ecd48d0535c3c8444a1d8df2926e457dce8e55fabc332a382c9c/moto-5.2.1-py3-none-any.whl", hash = "sha256:19d2fbd6e613aa5b4e364c52cd5d3cea371643a0f4210689a703227bd2924c5c", size = 6671379, upload-time = "2026-05-10T19:11:53.543Z" }, ] [[package]] @@ -3053,137 +3236,155 @@ wheels = [ ] [[package]] -name = "nvidia-cublas-cu12" -version = "12.8.4.1" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" }, -] - -[[package]] -name = "nvidia-cuda-cupti-cu12" -version = "12.8.90" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" }, -] - -[[package]] -name = "nvidia-cuda-nvrtc-cu12" -version = "12.8.93" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" }, -] - -[[package]] -name = "nvidia-cuda-runtime-cu12" -version = "12.8.90" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" }, -] - -[[package]] -name = "nvidia-cudnn-cu12" -version = "9.10.2.21" +name = "nvidia-cublas" +version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, ] [[package]] -name = "nvidia-cufft-cu12" -version = "11.3.3.83" +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" }, ] [[package]] -name = "nvidia-cufile-cu12" -version = "1.13.1.3" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" }, -] - -[[package]] -name = "nvidia-curand-cu12" -version = "10.3.9.90" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" }, -] - -[[package]] -name = "nvidia-cusolver-cu12" -version = "11.7.3.90" +name = "nvidia-cufft" +version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-cusparse-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-nvjitlink-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, ] [[package]] -name = "nvidia-cusparse-cu12" -version = "12.5.8.93" +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, ] [[package]] -name = "nvidia-cusparselt-cu12" -version = "0.7.1" +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, ] [[package]] -name = "nvidia-nccl-cu12" -version = "2.27.5" +name = "nvidia-nccl-cu13" +version = "2.29.7" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229, upload-time = "2025-06-26T04:11:28.385Z" }, + { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" }, + { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" }, ] [[package]] -name = "nvidia-nvjitlink-cu12" -version = "12.8.93" +name = "nvidia-nvjitlink" +version = "13.0.88" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" }, + { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" }, ] [[package]] -name = "nvidia-nvshmem-cu12" +name = "nvidia-nvshmem-cu13" version = "3.4.5" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd", size = 139103095, upload-time = "2025-09-06T00:32:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, ] [[package]] -name = "nvidia-nvtx-cu12" -version = "12.8.90" +name = "nvidia-nvtx" +version = "13.0.85" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, ] [[package]] @@ -3242,7 +3443,7 @@ wheels = [ [[package]] name = "openai" -version = "2.16.0" +version = "2.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3254,9 +3455,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/6c/e4c964fcf1d527fdf4739e7cc940c60075a4114d50d03871d5d5b1e13a88/openai-2.16.0.tar.gz", hash = "sha256:42eaa22ca0d8ded4367a77374104d7a2feafee5bd60a107c3c11b5243a11cd12", size = 629649, upload-time = "2026-01-27T23:28:02.579Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/36/4c926a91554483977608951360c18c2e911592785eb87a6437813f6123f7/openai-2.41.1.tar.gz", hash = "sha256:23d617a0432457ad844973bee8f540be9da90894f7c5686852d2d365da058f57", size = 783584, upload-time = "2026-06-10T16:10:37.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/83/0315bf2cfd75a2ce8a7e54188e9456c60cec6c0cf66728ed07bd9859ff26/openai-2.16.0-py3-none-any.whl", hash = "sha256:5f46643a8f42899a84e80c38838135d7038e7718333ce61396994f887b09a59b", size = 1068612, upload-time = "2026-01-27T23:28:00.356Z" }, + { url = "https://files.pythonhosted.org/packages/20/74/925d7b3892927e9804aaf58d374a45dc28e4420ff90e992272b77286343e/openai-2.41.1-py3-none-any.whl", hash = "sha256:a939565f350cb7443cb843b801b88c716ac8024b492fb94ca269d5f6b1bbefd6", size = 1353380, upload-time = "2026-06-10T16:10:35.756Z" }, ] [[package]] @@ -3645,11 +3846,11 @@ wheels = [ [[package]] name = "pip" -version = "26.0" +version = "26.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/44/c2/65686a7783a7c27a329706207147e82f23c41221ee9ae33128fc331670a0/pip-26.0.tar.gz", hash = "sha256:3ce220a0a17915972fbf1ab451baae1521c4539e778b28127efa79b974aff0fa", size = 1812654, upload-time = "2026-01-31T01:40:54.361Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/00/5ac7aa77688ec4d34148b423d34dc0c9bc4febe0d872a9a1ad9860b2f6f1/pip-26.0-py3-none-any.whl", hash = "sha256:98436feffb9e31bc9339cf369fd55d3331b1580b6a6f1173bacacddcf9c34754", size = 1787564, upload-time = "2026-01-31T01:40:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" }, ] [[package]] @@ -4166,34 +4367,34 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.12.0" +version = "2.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pyjwt" -version = "2.11.0" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -4203,21 +4404,21 @@ crypto = [ [[package]] name = "pylibseekdb" -version = "1.1.0" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/b8/c226744a7a1da9295725920a36867ee5665f2617972c7881d5ed4cbd45c8/pylibseekdb-1.1.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:0a0ad03d87f1db1a7087ba89e398ce1ee00496e977d38c493104d0d517590968", size = 148743770, upload-time = "2026-01-30T05:26:14.275Z" }, - { url = "https://files.pythonhosted.org/packages/51/4d/57151735afc29039f4ed680256012a33dd719ba3fd84d7c33a9bd260fc8a/pylibseekdb-1.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e272bee013aabab152c4795676b3b0ba1107a8058f29a07d2a803168faea090c", size = 147132528, upload-time = "2026-01-30T03:40:10.878Z" }, - { url = "https://files.pythonhosted.org/packages/88/d7/5583fbf27e89952cda52bb9b1919229bd652d02aafac156758ac862c48e7/pylibseekdb-1.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:116a28356532705ed262e2a7951ac8221ae8c97ade866fdab2df521dcca62530", size = 170696822, upload-time = "2026-01-30T03:40:18.417Z" }, - { url = "https://files.pythonhosted.org/packages/5d/2b/150592287119f80cff9b025d59879a561a0cca80e71cecbf74a41af6220b/pylibseekdb-1.1.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:d6ae33353e833cb56a7ce2cdb0305b872cdac9467eb79c277f82479c529b38ef", size = 148734111, upload-time = "2026-01-30T05:26:56.906Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a3/b55087293115ecbe22313b40533fd67b0192c36e6bedb05aa7058a83a86a/pylibseekdb-1.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9e2f8240b08a93e347d32534e7c394b7a151b67555a384eb88d73d4b0f8b9d14", size = 147137592, upload-time = "2026-01-30T03:40:26.087Z" }, - { url = "https://files.pythonhosted.org/packages/04/31/c0979960d790621dec277f64b5d6c70932f8bb9adb59029d7b481cfe9c30/pylibseekdb-1.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4d8615471bac39b1980951cbce0d742fa7bec676f28eb95f4db687fdd1e9c71b", size = 170681044, upload-time = "2026-01-30T03:40:34.276Z" }, - { url = "https://files.pythonhosted.org/packages/33/7d/8acbf3eca93905c1b13b015a9e02b426fc69c10e7c162be96b35a2b1c7a4/pylibseekdb-1.1.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d5688a0fe6fc703e5a707cbe0e139d570f1d34daff1491304d6b43154f2e12d9", size = 148743750, upload-time = "2026-01-30T05:27:39.832Z" }, - { url = "https://files.pythonhosted.org/packages/c8/24/7f510ad13ad129a691fa965dc5bce874320b682674cbf12fc2e35310719b/pylibseekdb-1.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1e53d171246239bd526d1a1f9b3abef1ad9b10597bc1c0a2acf7e65afbd7d844", size = 147136041, upload-time = "2026-01-30T03:40:41.782Z" }, - { url = "https://files.pythonhosted.org/packages/ed/eb/c5988e1ad72233a920f4e444d8d866c42363220b340d78a7525307922f35/pylibseekdb-1.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:66d01ee9c0ad4a2e88ea2420f9c4d1ee9bb011b70c553a654c8a4e230e920ad7", size = 170684140, upload-time = "2026-01-30T03:40:49.351Z" }, - { url = "https://files.pythonhosted.org/packages/9a/6f/b4a619c3a1b937fb080aa977b1d4011a1e587255707d54856188e5359a4c/pylibseekdb-1.1.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:11d2fbc98dcb8ec97257b949184dc09d9ba693811e77457bba9c8f80d282c265", size = 148745880, upload-time = "2026-01-30T05:38:26.631Z" }, - { url = "https://files.pythonhosted.org/packages/0c/94/534359608571d08825ac21e709aa680b559989c905f99e273d82d5b17db2/pylibseekdb-1.1.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ff05ac4bb13a4b5f9dd03771ded866beed72562ea497f68a4ae897c226afc446", size = 147132460, upload-time = "2026-01-30T03:40:56.684Z" }, - { url = "https://files.pythonhosted.org/packages/19/5e/7588a06918ac145fb69e57ae372b72d6fc713b9263c29eb7268f8a4edbef/pylibseekdb-1.1.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:065158b79192cce7635995a7599e99b21a3ff729cd6f68e31a65ed62f830bd3a", size = 170677921, upload-time = "2026-01-30T03:41:03.783Z" }, + { url = "https://files.pythonhosted.org/packages/23/1e/5d971387d4bcdcf0f6f3c85d681a207c49f20715cf566a88d2222e5cd4c0/pylibseekdb-1.3.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:1d33cf82f34339bc58ac160688fc7d15ac2f7cbb226338d3887fe8350f65b762", size = 142749176, upload-time = "2026-05-25T08:59:18.118Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/47f4a1ebad7e95169cfff1b87433b38623cc68426b3dfaac244c2492e5d4/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:77ba6786908cd8ab320ed4e5d5ef352759ef8990d72aff913467db5fe32542c4", size = 140878003, upload-time = "2026-05-25T06:11:51.929Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b1/c772c15444ddec07365c5728624824b7b2137c319398c3cfc44d2e6b09a3/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4b127c21ac1178ab903735041b6afe25295731d7bcee9813e5e1576c9d384937", size = 160132660, upload-time = "2026-05-25T06:12:02.817Z" }, + { url = "https://files.pythonhosted.org/packages/60/e8/d53bb80f6ed27f19dfb5b2f996cf9bef0e054442d473493e4f2425265762/pylibseekdb-1.3.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:23cd6ad60a80543dfccb4dc9500401347b82fddb8cef10f5503e5eb816adb39f", size = 142736028, upload-time = "2026-05-25T08:59:41.571Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e6/3811303e0740e45dd475e6cf8ccea2abb706f047e50455ec1834bdeb6068/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ec2465e206574f5dee7870bde2434a5ab9a03c2001786b1765fcb5dd790d6f98", size = 140881851, upload-time = "2026-05-25T06:12:11.973Z" }, + { url = "https://files.pythonhosted.org/packages/5d/29/856ea807cbe997c9fe2df6257106b2b2924ef9458bf87db7e4bd0b8dec03/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1b78f26dfbb80157169b81f22ebb80957e3c6ee7b33e5ff35beaa4d628c33915", size = 160133328, upload-time = "2026-05-25T06:12:22.051Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f1/5ec7782810746e9c065a419e8105a5925b3b04f495296b507706da9dc3b3/pylibseekdb-1.3.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:f6f739454aff786beeccfe71b66a0d89d01b5a8a260e0b8c5c30f8e9184bd88a", size = 142743219, upload-time = "2026-05-25T09:00:08.798Z" }, + { url = "https://files.pythonhosted.org/packages/13/8a/4d8150f6ad5f11dca40a6d42df9e2a41ed47125735a49afc7d2528460cd3/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:89069e1aeeb51f61aeaa0cf5d94bedb918f46c3476d7b30183dde7b2101e5954", size = 140884366, upload-time = "2026-05-25T06:12:31.689Z" }, + { url = "https://files.pythonhosted.org/packages/46/29/0583f2e00dbad80efffd7cb7df6431bd086b01a94d8b69688bae15a52e84/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2515ea14bbac59e6f9f90a43bbaf179050ad7f8ab683d1cb9fd7fe225ccdca4e", size = 160137143, upload-time = "2026-05-25T06:12:43.005Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5d/8c9afc77d32adbb1f7af85c3131419bcc9860677c5d6efb2d8d0ae9a7a66/pylibseekdb-1.3.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:a4177a3a6369699c9791cef3a7bfe7b472af301352237ed6e4cea42034fc0047", size = 142739982, upload-time = "2026-05-25T09:00:26.672Z" }, + { url = "https://files.pythonhosted.org/packages/56/91/bd3f9dea464cc22b454bbe384df3423e36e9fcbe7b1779c861f7ca9721e3/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:8651b8e0324fa78a5ed93b9952f4140c968655c344ef11fdb20d754077efeb05", size = 140896377, upload-time = "2026-05-25T06:12:53.468Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f4/fcf930ed8c6d40154f41edfb2054794c786dd66deced3a8cc3fef5898af7/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e6e58bce51e709c46aae3891e723b786132da925b9b6362db4486c07044d99e8", size = 160135373, upload-time = "2026-05-25T06:13:03.535Z" }, ] [[package]] @@ -4416,20 +4617,20 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-multipart" -version = "0.0.26" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, + { 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]] @@ -4726,7 +4927,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -4734,9 +4935,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -4767,15 +4968,15 @@ wheels = [ [[package]] name = "responses" version = "0.26.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, { name = "requests" }, { name = "urllib3" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303, upload-time = "2026-02-19T14:38:05.574Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303, upload-time = "2026-02-19T14:38:05.574Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099, upload-time = "2026-02-19T14:38:03.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099, upload-time = "2026-02-19T14:38:03.847Z" }, ] [[package]] @@ -5243,15 +5444,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] @@ -5462,68 +5663,46 @@ wheels = [ [[package]] name = "torch" -version = "2.10.0" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "filelock", marker = "python_full_version >= '3.14'" }, { name = "fsspec", marker = "python_full_version >= '3.14'" }, { name = "jinja2", marker = "python_full_version >= '3.14'" }, { name = "networkx", marker = "python_full_version >= '3.14'" }, - { name = "nvidia-cublas-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-cupti-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-nvrtc-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-runtime-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cufft-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cufile-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-curand-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusolver-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusparse-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvjitlink-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvshmem-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvtx-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "setuptools", marker = "python_full_version >= '3.14'" }, { name = "sympy", marker = "python_full_version >= '3.14'" }, - { name = "triton", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/8b/4b61d6e13f7108f36910df9ab4b58fd389cc2520d54d81b88660804aad99/torch-2.10.0-2-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:418997cb02d0a0f1497cf6a09f63166f9f5df9f3e16c8a716ab76a72127c714f", size = 79423467, upload-time = "2026-02-10T21:44:48.711Z" }, - { url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" }, - { url = "https://files.pythonhosted.org/packages/ec/23/2c9fe0c9c27f7f6cb865abcea8a4568f29f00acaeadfc6a37f6801f84cb4/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:e521c9f030a3774ed770a9c011751fb47c4d12029a3d6522116e48431f2ff89e", size = 79498254, upload-time = "2026-02-10T21:44:44.095Z" }, - { url = "https://files.pythonhosted.org/packages/36/ab/7b562f1808d3f65414cd80a4f7d4bb00979d9355616c034c171249e1a303/torch-2.10.0-3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:ac5bdcbb074384c66fa160c15b1ead77839e3fe7ed117d667249afce0acabfac", size = 915518691, upload-time = "2026-03-11T14:15:43.147Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6", size = 915622781, upload-time = "2026-03-11T14:17:11.354Z" }, - { url = "https://files.pythonhosted.org/packages/ab/c6/4dfe238342ffdcec5aef1c96c457548762d33c40b45a1ab7033bb26d2ff2/torch-2.10.0-3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:80b1b5bfe38eb0e9f5ff09f206dcac0a87aadd084230d4a36eea5ec5232c115b", size = 915627275, upload-time = "2026-03-11T14:16:11.325Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f0/72bf18847f58f877a6a8acf60614b14935e2f156d942483af1ffc081aea0/torch-2.10.0-3-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:46b3574d93a2a8134b3f5475cfb98e2eb46771794c57015f6ad1fb795ec25e49", size = 915523474, upload-time = "2026-03-11T14:17:44.422Z" }, - { url = "https://files.pythonhosted.org/packages/f4/39/590742415c3030551944edc2ddc273ea1fdfe8ffb2780992e824f1ebee98/torch-2.10.0-3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b1d5e2aba4eb7f8e87fbe04f86442887f9167a35f092afe4c237dfcaaef6e328", size = 915632474, upload-time = "2026-03-11T14:15:13.666Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8e/34949484f764dde5b222b7fe3fede43e4a6f0da9d7f8c370bb617d629ee2/torch-2.10.0-3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0228d20b06701c05a8f978357f657817a4a63984b0c90745def81c18aedfa591", size = 915523882, upload-time = "2026-03-11T14:14:46.311Z" }, - { url = "https://files.pythonhosted.org/packages/78/89/f5554b13ebd71e05c0b002f95148033e730d3f7067f67423026cc9c69410/torch-2.10.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3282d9febd1e4e476630a099692b44fdc214ee9bf8ee5377732d9d9dfe5712e4", size = 145992610, upload-time = "2026-01-21T16:25:26.327Z" }, - { url = "https://files.pythonhosted.org/packages/ae/30/a3a2120621bf9c17779b169fc17e3dc29b230c29d0f8222f499f5e159aa8/torch-2.10.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2f9edd8dbc99f62bc4dfb78af7bf89499bca3d753423ac1b4e06592e467b763", size = 915607863, upload-time = "2026-01-21T16:25:06.696Z" }, - { url = "https://files.pythonhosted.org/packages/6f/3d/c87b33c5f260a2a8ad68da7147e105f05868c281c63d65ed85aa4da98c66/torch-2.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:29b7009dba4b7a1c960260fc8ac85022c784250af43af9fb0ebafc9883782ebd", size = 113723116, upload-time = "2026-01-21T16:25:21.916Z" }, - { url = "https://files.pythonhosted.org/packages/61/d8/15b9d9d3a6b0c01b883787bd056acbe5cc321090d4b216d3ea89a8fcfdf3/torch-2.10.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:b7bd80f3477b830dd166c707c5b0b82a898e7b16f59a7d9d42778dd058272e8b", size = 79423461, upload-time = "2026-01-21T16:24:50.266Z" }, - { url = "https://files.pythonhosted.org/packages/cc/af/758e242e9102e9988969b5e621d41f36b8f258bb4a099109b7a4b4b50ea4/torch-2.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5fd4117d89ffd47e3dcc71e71a22efac24828ad781c7e46aaaf56bf7f2796acf", size = 145996088, upload-time = "2026-01-21T16:24:44.171Z" }, - { url = "https://files.pythonhosted.org/packages/23/8e/3c74db5e53bff7ed9e34c8123e6a8bfef718b2450c35eefab85bb4a7e270/torch-2.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:787124e7db3b379d4f1ed54dd12ae7c741c16a4d29b49c0226a89bea50923ffb", size = 915711952, upload-time = "2026-01-21T16:23:53.503Z" }, - { url = "https://files.pythonhosted.org/packages/6e/01/624c4324ca01f66ae4c7cd1b74eb16fb52596dce66dbe51eff95ef9e7a4c/torch-2.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2c66c61f44c5f903046cc696d088e21062644cbe541c7f1c4eaae88b2ad23547", size = 113757972, upload-time = "2026-01-21T16:24:39.516Z" }, - { url = "https://files.pythonhosted.org/packages/c9/5c/dee910b87c4d5c0fcb41b50839ae04df87c1cfc663cf1b5fca7ea565eeaa/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:6d3707a61863d1c4d6ebba7be4ca320f42b869ee657e9b2c21c736bf17000294", size = 79498198, upload-time = "2026-01-21T16:24:34.704Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6f/f2e91e34e3fcba2e3fc8d8f74e7d6c22e74e480bbd1db7bc8900fdf3e95c/torch-2.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5c4d217b14741e40776dd7074d9006fd28b8a97ef5654db959d8635b2fe5f29b", size = 146004247, upload-time = "2026-01-21T16:24:29.335Z" }, - { url = "https://files.pythonhosted.org/packages/98/fb/5160261aeb5e1ee12ee95fe599d0541f7c976c3701d607d8fc29e623229f/torch-2.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6b71486353fce0f9714ca0c9ef1c850a2ae766b409808acd58e9678a3edb7738", size = 915716445, upload-time = "2026-01-21T16:22:45.353Z" }, - { url = "https://files.pythonhosted.org/packages/6a/16/502fb1b41e6d868e8deb5b0e3ae926bbb36dab8ceb0d1b769b266ad7b0c3/torch-2.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2ee399c644dc92ef7bc0d4f7e74b5360c37cdbe7c5ba11318dda49ffac2bc57", size = 113757050, upload-time = "2026-01-21T16:24:19.204Z" }, - { url = "https://files.pythonhosted.org/packages/1a/0b/39929b148f4824bc3ad6f9f72a29d4ad865bcf7ebfc2fa67584773e083d2/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:3202429f58309b9fa96a614885eace4b7995729f44beb54d3e4a47773649d382", size = 79851305, upload-time = "2026-01-21T16:24:09.209Z" }, - { url = "https://files.pythonhosted.org/packages/d8/14/21fbce63bc452381ba5f74a2c0a959fdf5ad5803ccc0c654e752e0dbe91a/torch-2.10.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:aae1b29cd68e50a9397f5ee897b9c24742e9e306f88a807a27d617f07adb3bd8", size = 146005472, upload-time = "2026-01-21T16:22:29.022Z" }, - { url = "https://files.pythonhosted.org/packages/54/fd/b207d1c525cb570ef47f3e9f836b154685011fce11a2f444ba8a4084d042/torch-2.10.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6021db85958db2f07ec94e1bc77212721ba4920c12a18dc552d2ae36a3eb163f", size = 915612644, upload-time = "2026-01-21T16:21:47.019Z" }, - { url = "https://files.pythonhosted.org/packages/36/53/0197f868c75f1050b199fe58f9bf3bf3aecac9b4e85cc9c964383d745403/torch-2.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff43db38af76fda183156153983c9a096fc4c78d0cd1e07b14a2314c7f01c2c8", size = 113997015, upload-time = "2026-01-21T16:23:00.767Z" }, - { url = "https://files.pythonhosted.org/packages/0e/13/e76b4d9c160e89fff48bf16b449ea324bda84745d2ab30294c37c2434c0d/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:cdf2a523d699b70d613243211ecaac14fe9c5df8a0b0a9c02add60fb2a413e0f", size = 79498248, upload-time = "2026-01-21T16:23:09.315Z" }, - { url = "https://files.pythonhosted.org/packages/4f/93/716b5ac0155f1be70ed81bacc21269c3ece8dba0c249b9994094110bfc51/torch-2.10.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:bf0d9ff448b0218e0433aeb198805192346c4fd659c852370d5cc245f602a06a", size = 79464992, upload-time = "2026-01-21T16:23:05.162Z" }, - { url = "https://files.pythonhosted.org/packages/69/2b/51e663ff190c9d16d4a8271203b71bc73a16aa7619b9f271a69b9d4a936b/torch-2.10.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:233aed0659a2503b831d8a67e9da66a62c996204c0bba4f4c442ccc0c68a3f60", size = 146018567, upload-time = "2026-01-21T16:22:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/5e/cd/4b95ef7f293b927c283db0b136c42be91c8ec6845c44de0238c8c23bdc80/torch-2.10.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:682497e16bdfa6efeec8cde66531bc8d1fbbbb4d8788ec6173c089ed3cc2bfe5", size = 915721646, upload-time = "2026-01-21T16:21:16.983Z" }, - { url = "https://files.pythonhosted.org/packages/56/97/078a007208f8056d88ae43198833469e61a0a355abc0b070edd2c085eb9a/torch-2.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:6528f13d2a8593a1a412ea07a99812495bec07e9224c28b2a25c0a30c7da025c", size = 113752373, upload-time = "2026-01-21T16:22:13.471Z" }, - { url = "https://files.pythonhosted.org/packages/d8/94/71994e7d0d5238393df9732fdab607e37e2b56d26a746cb59fdb415f8966/torch-2.10.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f5ab4ba32383061be0fb74bda772d470140a12c1c3b58a0cfbf3dae94d164c28", size = 79850324, upload-time = "2026-01-21T16:22:09.494Z" }, - { url = "https://files.pythonhosted.org/packages/e2/65/1a05346b418ea8ccd10360eef4b3e0ce688fba544e76edec26913a8d0ee0/torch-2.10.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:716b01a176c2a5659c98f6b01bf868244abdd896526f1c692712ab36dbaf9b63", size = 146006482, upload-time = "2026-01-21T16:22:18.42Z" }, - { url = "https://files.pythonhosted.org/packages/1d/b9/5f6f9d9e859fc3235f60578fa64f52c9c6e9b4327f0fe0defb6de5c0de31/torch-2.10.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d8f5912ba938233f86361e891789595ff35ca4b4e2ac8fe3670895e5976731d6", size = 915613050, upload-time = "2026-01-21T16:20:49.035Z" }, - { url = "https://files.pythonhosted.org/packages/66/4d/35352043ee0eaffdeff154fad67cd4a31dbed7ff8e3be1cc4549717d6d51/torch-2.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:71283a373f0ee2c89e0f0d5f446039bdabe8dbc3c9ccf35f0f784908b0acd185", size = 113995816, upload-time = "2026-01-21T16:22:05.312Z" }, + { url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e3/750b3e3548635ceac03ba255daa26dbc7ed66ca3484dc4b4d955ab7f4501/torch-2.12.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:107df6888624bdea41508f9aeb6149d9333c737a5530ceecb56c904e811369ae", size = 426379894, upload-time = "2026-06-17T21:06:55.077Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ca/ed24783da629ff3e640ba3f70a7639e9045d3d88b93ee6bc47b8a28a1f2c/torch-2.12.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6e29e7e74d05bda7d955c75e99459f878ebd970ef851b4057edbd3b34a5eb4a3", size = 532169264, upload-time = "2026-06-17T21:08:17.65Z" }, + { url = "https://files.pythonhosted.org/packages/46/61/c63f0158446f3a98ea672b004d761b848911eba567ea4a624c7db5aadc04/torch-2.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:a513506cfda3c1c78dabeb6574c1597538c0254b3d39af174dde35d8177f4ce3", size = 122953086, upload-time = "2026-06-17T21:08:27.69Z" }, + { url = "https://files.pythonhosted.org/packages/f0/54/efb7ebca77970012b0cc21687a55d70eb2ba514b2c2b8e18d9fb1222f3be/torch-2.12.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d2dd0f2c5f7ccbddaf34cade0deaf476808368f902b9cdb7f36a2ab42301bc0e", size = 87991951, upload-time = "2026-06-17T21:07:49.309Z" }, + { url = "https://files.pythonhosted.org/packages/1e/00/4210d76ca7424981f04033ebe7e48816ab83287a62538747a58825db770c/torch-2.12.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:2de4e19b88a481482c6c75291f2d6a52eda3ce51f311b29aa9b68499c830c07c", size = 426382721, upload-time = "2026-06-17T21:06:41.842Z" }, + { url = "https://files.pythonhosted.org/packages/76/1f/bc9f5a5aa569307076365f25afcebacb22e9c754b1bcfbaaa146627c7fda/torch-2.12.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:649e4ced014ba646f76f8cb9c9726735a6323eb321b7919f942790a923f90921", size = 532261322, upload-time = "2026-06-17T21:06:06.673Z" }, + { url = "https://files.pythonhosted.org/packages/9e/49/c549461daa008159d006a76a991fbc2f26fa8bac27a4030c858463dcb20f/torch-2.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:e86550597877fb272ddc52db2f85b82cb601ea7bd932576a0340152cae2200b3", size = 122988095, upload-time = "2026-06-17T21:07:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4a/0300261818e1560d72cc160ac826005507e8b7ca0a35788b591436d05b4a/torch-2.12.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c75e93173c700bccd6bfcc4a9d19ce242ab6dacd1f1781483027a16239b9e650", size = 87992358, upload-time = "2026-06-17T21:07:40.299Z" }, + { url = "https://files.pythonhosted.org/packages/30/a7/874a5ca05e8f159211dca7921060f7057acc1adb26431e119fd150623efc/torch-2.12.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:fcb61ccd20784b62bdd78ec84238a5cfb383b4994902e03bac95505ab360884c", size = 426386134, upload-time = "2026-06-17T21:07:31.481Z" }, + { url = "https://files.pythonhosted.org/packages/e1/75/20bb8fe9c1ad6538cce8cd0391b51927ae5af0b17ed1eab44b8824465dc1/torch-2.12.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f4afc8083dff08719edbea346644476e3cec0cf40ebe256be0ee5d5b7c7e8c0d", size = 532268019, upload-time = "2026-06-17T21:05:37.925Z" }, + { url = "https://files.pythonhosted.org/packages/d1/fa/824ddb662af55b2eabc0dbb7b57c7c0b1bcd93693754a2b8509ec4d16490/torch-2.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:f92609e3b3ce72f25e2eb780d043ced2480c1a86c47c852604fc7a9108648386", size = 122987777, upload-time = "2026-06-17T21:07:09.49Z" }, + { url = "https://files.pythonhosted.org/packages/63/b7/1b49fe7086ea36839cc80abc43174c43d0ab6f676c0891c871c162f44fe3/torch-2.12.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e9b6f7d2dd66ea87a3ae620069d31335d594c06effb1a383bdd21cfe61e44ece", size = 88010025, upload-time = "2026-06-17T21:07:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/d7/06/5b44063a6545036dcc680d2d303b137d9176cfb2cc1e1863e3ef94abeb52/torch-2.12.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:7973ccd3d2cd35c74449213f7bded199bec6c6247e705cbeda7407af79703d91", size = 426392891, upload-time = "2026-06-17T21:05:52.261Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dd/c9ce9a4b0eb3c5bb92d9ea56766e2c22559f0b45171149188494edcce80f/torch-2.12.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c64ac4aac16be5e296dcd912305605804b203333c690bf98c55bc09494ee92ad", size = 532272494, upload-time = "2026-06-17T21:06:22.72Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/f3a601fc1b1f663ff269bfe553654e638651939aa6563e8daa7167c33098/torch-2.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:f6dc4caf7eb4adb38a2d9f536b51db56310fdd1254e69a2d96767e1367c892b3", size = 122987254, upload-time = "2026-06-17T21:06:33.199Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/b8087556cf81ddd808dbeb34afb8396d7ae7a1694ab489f08b1a0004e7d0/torch-2.12.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:2afbb2bdaa8a95040e733f05492ddf133c3967c9b7ce0abd218d704b6cab437d", size = 88303173, upload-time = "2026-06-17T21:05:06.603Z" }, + { url = "https://files.pythonhosted.org/packages/4a/07/fe09d1699fbed2afa10ebc692ff2b99d113f2605b6748cea633989e2789a/torch-2.12.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:97eba061fcb042fed191400b15568990073d67eaacaa6ee9b7ca01dd8b790fe9", size = 426404009, upload-time = "2026-06-17T21:04:57.557Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f7/0ce4f6c1962c60ded7270e0a9eb560fb615c92b89d332cf9e3dff36d5ecc/torch-2.12.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3867b861391701012adb2df93360efb88494dca245a185e3bb7624495cfe3f33", size = 532184292, upload-time = "2026-06-17T21:05:17.526Z" }, + { url = "https://files.pythonhosted.org/packages/70/db/e384c12aba30320ca92aaaf557456cbcb26f04b4df307728bb8f019f5000/torch-2.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dd15595f8fc764cffde8c6361a3beb6ef69a028c851b1b3e70e077f615980d4e", size = 123231142, upload-time = "2026-06-17T21:05:27.061Z" }, ] [[package]] @@ -5560,15 +5739,19 @@ wheels = [ [[package]] name = "triton" -version = "3.6.0" +version = "3.7.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/12/b05ba554d2c623bffa59922b94b0775673de251f468a9609bc9e45de95e9/triton-3.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e323d608e3a9bfcc2d9efcc90ceefb764a82b99dea12a86d643c72539ad5d3", size = 188214640, upload-time = "2026-01-20T16:00:35.869Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" }, - { url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" }, - { url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" }, - { url = "https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803", size = 188273063, upload-time = "2026-01-20T16:01:07.278Z" }, - { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f9/19d842d06a08559534fa1eaab6ca551b1bcf40f06620bddec1babaa2772d/triton-3.7.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a0e1cd4c4a76370ed74a8432a53cea28716827d19e40ffc732233e35ceb3f6", size = 184664887, upload-time = "2026-06-17T20:03:42.913Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5e/fce69606f7f240297f163e25539906732b199530d486ce67ae319877e821/triton-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6744957e9fd610a29680ec2346057d0c86948ed3812468670719f391e94b44a5", size = 197701306, upload-time = "2026-06-17T19:53:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/94/fa/f856e24deb462d5f18bd4b5a746957862ab9b6ee5834bda60605ec348366/triton-3.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9497f2e696ee368862a181a90b2dcc03ca978cc4f602abd67c7d81022a6988e1", size = 184692359, upload-time = "2026-06-17T20:03:48.288Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6f/fb96d15db6f36d6eae4cafb998c2e0353bf59d7c4ea1662d7497f269134a/triton-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728", size = 197719725, upload-time = "2026-06-17T19:53:20.419Z" }, + { url = "https://files.pythonhosted.org/packages/00/42/c5089d4d9327fcd1e862c599cc2927f39418f84dd11a84cb2ccff9d4787a/triton-3.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdbfc09d9ec58bc5e68321525653220de7515c199e7a8097a97c85e62b52cd0a", size = 184694629, upload-time = "2026-06-17T20:03:53.444Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/2c3ac59253ae8892b6f307875263dd23dc875cdf732d3aea40d6d41fb7cb/triton-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58c0e131da05134a2a4788ccbcc0c1105cf0f54c8e98f19e34cd465396dc15eb", size = 197729241, upload-time = "2026-06-17T19:53:27.801Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/e01aa7ad573883ed9456f130226babdec70b005e098c4d6226a6238e761b/triton-3.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4ea396a06171f1f1f58cbd39c70b09294398f7dd7c620939bab54ad6f934fa", size = 184705764, upload-time = "2026-06-17T20:03:59.064Z" }, + { url = "https://files.pythonhosted.org/packages/a4/09/5683146fda6a2b569deb78ccfd8fbfea8bfe55f726b081c0a6bb18dd6f28/triton-3.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2020153b08280415ec0da6607834e79166442147e78e144df06b508c75b186d2", size = 197729537, upload-time = "2026-06-17T19:53:35.516Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/448220c3092019f9fdfab39ec47985968181d67da34b44f6a7f6280a5cbb/triton-3.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c58e4c61f0c73b5dba3b5d19b4a7093c32f90dc18b2a7f121a7c16ccd31107b7", size = 184814760, upload-time = "2026-06-17T20:04:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ac/229b7d4589d2e5937310e72c6d46e89599d16a4a12b479ffa1499fee8eb8/triton-3.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68", size = 197824404, upload-time = "2026-06-17T19:53:42.772Z" }, ] [[package]] @@ -5679,11 +5862,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] @@ -5717,28 +5900,28 @@ wheels = [ [[package]] name = "uv" -version = "0.11.7" +version = "0.11.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9b/7d/17750123a8c8e324627534fe1ae2e7a46689db8492f1a834ab4fd229a7d8/uv-0.11.7.tar.gz", hash = "sha256:46d971489b00bdb27e0aa715e4a5cd4ef2c28ea5b6ef78f2b67bf861eb44b405", size = 4083385, upload-time = "2026-04-15T21:42:55.474Z" } +sdist = { url = "https://files.pythonhosted.org/packages/67/f0/6254502aebfdc0a9df6069269a126dd58252ac29d2d6cdf4777cea3e90b5/uv-0.11.19.tar.gz", hash = "sha256:f56f5bf853626a30423052d7ee00bf5cc940a08347d6ee7ede96862d084054a5", size = 4213580, upload-time = "2026-06-03T22:37:15.976Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/5b/2bb2ab6fe6c78c2be10852482ef0cae5f3171460a6e5e24c32c9a0843163/uv-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:f422d39530516b1dfb28bb6e90c32bb7dacd50f6a383cd6e40c1a859419fbc8c", size = 23757265, upload-time = "2026-04-15T21:43:14.494Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f5/36ff27b01e60a88712628c8a5a6003b8e418883c24e084e506095844a797/uv-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8b2fe1ec6775dad10183e3fdce430a5b37b7857d49763c884f3a67eaa8ca6f8a", size = 23184529, upload-time = "2026-04-15T21:42:30.225Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fa/f379be661316698f877e78f4c51e5044be0b6f390803387237ad92c4057f/uv-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:162fa961a9a081dcea6e889c79f738a5ae56507047e4672964972e33c301bea9", size = 21780167, upload-time = "2026-04-15T21:42:44.942Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/fbed29775b0612f4f5679d3226268f1a347161abc1727b4080fb41d9f46f/uv-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:5985a15a92bd9a170fc1947abb1fbc3e9828c5a430ad85b5bed8356c20b67a71", size = 23609640, upload-time = "2026-04-15T21:42:22.57Z" }, - { url = "https://files.pythonhosted.org/packages/ad/de/989a69634a869a22322770120557c2d8cbba5b77ec7cfad326b4ec0f0547/uv-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:fab0bb43fbbc0ee5b5fee212078d2300c371b725faff7cf72eeaafa0bff0606b", size = 23322484, upload-time = "2026-04-15T21:43:26.52Z" }, - { url = "https://files.pythonhosted.org/packages/24/08/c1af05ea602eb4eb75d86badb6b0594cc104c3ca83ccf06d9ed4dd2186ad/uv-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:23d457d6731ebdb83f1bffebe4894edab2ef43c1ec5488433c74300db4958924", size = 23326385, upload-time = "2026-04-15T21:42:41.32Z" }, - { url = "https://files.pythonhosted.org/packages/68/99/e246962da06383e992ecab55000c62a50fb36efef855ea7264fad4816bf4/uv-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d6a17507b8139b8803f445a03fd097f732ce8356b1b7b13cdb4dd8ef7f4b2e0", size = 24985751, upload-time = "2026-04-15T21:42:37.777Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/b0b68083859579ce811996c1480765ec6a2442b44c451eaef53e6218fbae/uv-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd48823ca4b505124389f49ae50626ba9f57212b9047738efc95126ed5f3844d", size = 25724160, upload-time = "2026-04-15T21:43:18.762Z" }, - { url = "https://files.pythonhosted.org/packages/4e/19/5970e89d9e458fd3c4966bbc586a685a1c0ab0a8bf334503f63fa20b925b/uv-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb91f52ee67e10d5290f2c2897e2171357f1a10966de38d83eefa93d96843b0c", size = 25028512, upload-time = "2026-04-15T21:43:02.721Z" }, - { url = "https://files.pythonhosted.org/packages/83/eb/4e1557daf6693cb446ed28185664ad6682fd98c6dbac9e433cbc35df450a/uv-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e4d5e31bea86e1b6e0f5a0f95e14e80018e6f6c0129256d2915a4b3d793644d", size = 24933975, upload-time = "2026-04-15T21:42:18.828Z" }, - { url = "https://files.pythonhosted.org/packages/68/55/3b517ec8297f110d6981f525cccf26f86e30883fbb9c282769cffbcdcfca/uv-0.11.7-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ceae53b202ea92bc954759bc7c7570cdcd5c3512fce15701198c19fd2dfb8605", size = 23706403, upload-time = "2026-04-15T21:43:10.664Z" }, - { url = "https://files.pythonhosted.org/packages/dc/30/7d93a0312d60e147722967036dc8ea37baab4802784bddc22464cb707deb/uv-0.11.7-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:f97e9f4e4d44fb5c4dfaa05e858ef3414a96416a2e4af270ecd88a3e5fb049a9", size = 24495797, upload-time = "2026-04-15T21:42:26.538Z" }, - { url = "https://files.pythonhosted.org/packages/8c/89/d49480bdab7725d36982793857e461d471bde8e1b7f438ffccee677a7bf8/uv-0.11.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:750ee5b96959b807cf442b73dd8b55111862d63f258f896787ea5f06b68aaca9", size = 24580471, upload-time = "2026-04-15T21:42:52.871Z" }, - { url = "https://files.pythonhosted.org/packages/b6/9f/c57dc03b48be17b564e304eb9ff982890c12dfb888b1ce370788733329ab/uv-0.11.7-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f394331f0507e80ee732cb3df737589de53bed999dd02a6d24682f08c2f8ac4f", size = 24113637, upload-time = "2026-04-15T21:42:34.094Z" }, - { url = "https://files.pythonhosted.org/packages/13/ba/b87e358b629a68258527e3490e73b7b148770f4d2257842dea3b7981d4e8/uv-0.11.7-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0df59ab0c6a4b14a763e8445e1c303af9abeb53cdfa4428daf9ff9642c0a3cce", size = 25119850, upload-time = "2026-04-15T21:43:22.529Z" }, - { url = "https://files.pythonhosted.org/packages/4b/74/16d229e1d8574bcbafa6dc643ac20b70c3e581f42ac31a6f4fd53035ffe3/uv-0.11.7-py3-none-win32.whl", hash = "sha256:553e67cc766d013ce24353fecd4ea5533d2aedcfd35f9fac430e07b1d1f23ed4", size = 22918454, upload-time = "2026-04-15T21:42:58.702Z" }, - { url = "https://files.pythonhosted.org/packages/a6/1d/b73e473da616ac758b8918fb218febcc46ddf64cba9e03894dfa226b28bd/uv-0.11.7-py3-none-win_amd64.whl", hash = "sha256:5674dfb5944513f4b3735b05c2deba6b1b01151f46729d533d413a9a905f8c5d", size = 25447744, upload-time = "2026-04-15T21:42:48.813Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bb/e6bfdea92ed270f3445a5a3c17599d041b3f2dbc5026c09e02830a03bbaf/uv-0.11.7-py3-none-win_arm64.whl", hash = "sha256:6158b7e39464f1aa1e040daa0186cae4749a78b5cd80ac769f32ca711b8976b1", size = 23941816, upload-time = "2026-04-15T21:43:06.732Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/be32c2f6ba30fa9d8b3baceb478107cc23722d4aaab87145a332e4985185/uv-0.11.19-py3-none-linux_armv6l.whl", hash = "sha256:c729f56ffef9b945053412c839695e8a0b13758aa15b7763e95a7dd539a6f522", size = 23620003, upload-time = "2026-06-03T22:37:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ed/3aefe4a4ca4ac9204c6745670dbe12f4add69194d40f5abd1c7bd45ba9af/uv-0.11.19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a98495b9dd67287d8c1a0786f98cb037a50f0ee6c3d648572edaa7137aabc277", size = 23183211, upload-time = "2026-06-03T22:37:20.699Z" }, + { url = "https://files.pythonhosted.org/packages/5b/eb/5d1469f9e709d56066f292978711fbf1f805b7fb46f901d3c1f260fd9908/uv-0.11.19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fdd881cd6d80782afcf8c1d446dd15a42985167fd812b763d38ba1e4a8d944d", size = 21754003, upload-time = "2026-06-03T22:37:05.027Z" }, + { url = "https://files.pythonhosted.org/packages/7b/93/109b5ee6678f54492f94fdef74149643eaa1f2f4716906a2a10816b31247/uv-0.11.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:7222f45b5541551057bfc2e3021f113800704f665c119fdf3ea700c6c4859b21", size = 23518832, upload-time = "2026-06-03T22:37:28.794Z" }, + { url = "https://files.pythonhosted.org/packages/08/0c/8c59bbcf78e94ca9994256920efa99d1c4dc9d0b966eb62ebba075585a16/uv-0.11.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:2e0e0b8ad59ec56f1440d6e4313b64a1d8119275dcec73d19eef33c43f99428c", size = 23163128, upload-time = "2026-06-03T22:37:23.226Z" }, + { url = "https://files.pythonhosted.org/packages/89/d6/69caf9e6f11c84b5fb92df190b46fbecb7dc6645ae891c6ed66d7aaaa310/uv-0.11.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4aa17ffd719daf37b7a6265efd3ee4922a8ddaabaf0406d2b28c7e5ce2f20ff", size = 23164395, upload-time = "2026-06-03T22:37:18.11Z" }, + { url = "https://files.pythonhosted.org/packages/d6/83/0c2242b77c51ac33a0ddd8b06790429a0b8b9623974c9594ab2b0070ec47/uv-0.11.19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32d7988c0dfb6f90941f201c871a4478e96e4f2a32bdb2256d62a78ee20593fc", size = 24541708, upload-time = "2026-06-03T22:37:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/54/10/b1404fc52c0eddc3655f57a8b76e79dcf8dd02568382272f17e2fa68c4bb/uv-0.11.19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2d663bacb97e2e8412d1c26eace28c7ebbde9d6f5d7d78760fafd114d693817f", size = 25575501, upload-time = "2026-06-03T22:37:47.526Z" }, + { url = "https://files.pythonhosted.org/packages/7c/17/4cda5994195ba9ce1f6971d40d5f2ceec58e2a79030d9052b3bf322557b1/uv-0.11.19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:574f5dd4f31666661ea6386d3b91c5f0e8b84a8cae98ebba447c4674f2e6a4c7", size = 24827200, upload-time = "2026-06-03T22:37:34.039Z" }, + { url = "https://files.pythonhosted.org/packages/5a/74/2bd8b51e1d76210fd424ae55ec3f34ded5a10eeff3dd38aeb03c816a0af2/uv-0.11.19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:731d9fab8db5d41590af64236d03f8069c8da665fd0f9493b85985f19c86cd90", size = 24872664, upload-time = "2026-06-03T22:37:11.301Z" }, + { url = "https://files.pythonhosted.org/packages/06/b1/44b0764f656bbdd0728118610a63f2feddd9cbe450f974d80c5bb56aad34/uv-0.11.19-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:301fd78309fc545c2cec2bfcc61a6bbdde876856c6d2041502737cf44085c178", size = 23617890, upload-time = "2026-06-03T22:37:44.796Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/312fa33cd4c34e7618f86cad0c9fdb312d8fef2e7fc61944c1a2f1bf1256/uv-0.11.19-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:62b0b35a51d3034ff30ecd0f381e9bbc20d5b335754f54b098da29424d551ceb", size = 24267220, upload-time = "2026-06-03T22:37:39.425Z" }, + { url = "https://files.pythonhosted.org/packages/8d/25/13856aeff9e14c98ee3e1ceae4d209301cbdeabde93abcd758433601dc82/uv-0.11.19-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65e932720daed1af1f720a0ff5f9b33ee5f7ad97488dcceceb85154fc1323b82", size = 24376177, upload-time = "2026-06-03T22:37:50.276Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/590b3ab420e03504cf658d2981e1fcb4af60f3858d42da1d4d8740141dd9/uv-0.11.19-py3-none-musllinux_1_1_i686.whl", hash = "sha256:8f90b6687a480d154595aa619fb836a9a20d00ce37293db8099aad924f2b18f9", size = 23808336, upload-time = "2026-06-03T22:37:26.086Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8e/40acebd4ea419c870930580623e8367e23d810a0ecb8cc2f44d852a27293/uv-0.11.19-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:28b0d612a766eb25756dbaa315433b726e93affa467d29a2682cc317547952ba", size = 25080747, upload-time = "2026-06-03T22:37:13.886Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d3/4037b2acb2bb73b1a3ee47a1d23864ecc503f5840387afd29f621d4fd2ec/uv-0.11.19-py3-none-win32.whl", hash = "sha256:aa6a7e8d07b33ad22f4732848ebb1d9486503973c248d6e632c06ce4339fe347", size = 22459533, upload-time = "2026-06-03T22:37:36.741Z" }, + { url = "https://files.pythonhosted.org/packages/d4/43/f374fad7ad94e4a8c47cf09f00d803c76c6cc7f225668c41f4e2fb5de000/uv-0.11.19-py3-none-win_amd64.whl", hash = "sha256:480fc34a8d0967af6a90b3f99a6e5687cd5c6e29528de96bec04d6e305a59363", size = 25143888, upload-time = "2026-06-03T22:37:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/18/98/d2db53ae036528b0a9407529ef175ee200b01f626c9c160978784c8af870/uv-0.11.19-py3-none-win_arm64.whl", hash = "sha256:50e4d4796ca1a6da359a4f723a0fea86640c381d3ff4fa759a41badd7cb52dee", size = 23601290, upload-time = "2026-06-03T22:37:31.393Z" }, ] [[package]] @@ -5803,6 +5986,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] +[[package]] +name = "valkey-glide" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "protobuf" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/60/961ce40492a56ef831a905dfe03df4a81c0705152f6a8e49c541c634f49e/valkey_glide-2.4.1-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:d7285d03c2df040f26874b7f4ae96f040da2daecc9a34fa99da6f4e6ce5149c8", size = 7482152, upload-time = "2026-05-28T21:41:02.205Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b2/5a05567f0fc385dcbbbf6ab1061f0bc00443d51c2996e95eed45feaedda9/valkey_glide-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d2e82b74127897ccb7a957ad455787816a75fdc8c60a5e8004aef65ea93e99c", size = 6928601, upload-time = "2026-05-28T21:41:04.543Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d9/7ea2b47cff0a2f99921eb0db404215f828ced7814bd09ede9c93b65d20bc/valkey_glide-2.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4094128cb07e06e87013b7afab1e9388f8f5aeebe48ea6cbd54de15bd772e644", size = 7236977, upload-time = "2026-05-28T21:41:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/00/7a/6cda6b42156ed260e765e4ad2d6ab831607775e218a00fbb0d93411c4e8f/valkey_glide-2.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f8dc0f3a36adb1cbe4e167972ca4758acdfed6baf58a4db94bbb713df56c8f5", size = 7691446, upload-time = "2026-05-28T21:41:07.833Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/da8c058baaee414a6bb2450742359f3b3b6993b23281bf227c5089f0099c/valkey_glide-2.4.1-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:5f8df64f6a4f0fd7203113103101fdf0aaa7ff0e7557312611de11ab89c6db75", size = 7472646, upload-time = "2026-05-28T21:41:09.451Z" }, + { url = "https://files.pythonhosted.org/packages/f5/94/e1e311cb56597272b9cb69afb3fe8e2e7dd3371f88c92836015deddc6f49/valkey_glide-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b45e35f44c17e88f8cd8082f8d8061a9763238c44ef20b11b615f6d87235864a", size = 6943375, upload-time = "2026-05-28T21:41:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/76/00/0e42e2f6866ebf0de552e076dc585a487b488b5b818c52460d28b50de65b/valkey_glide-2.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf812b498925a30abab6e1a9f82f5eb821e967904fe7724729b2c82c47e29edf", size = 7237469, upload-time = "2026-05-28T21:41:12.733Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4c/c5dd9a1ed995453b0d9ca75a5af87e881c14e6eebdbf5a5fa78c3bae23fc/valkey_glide-2.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:214e2faca98966eea3eaf9e09de616862423815a5059843a9884125e2427a344", size = 7678744, upload-time = "2026-05-28T21:41:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2f/3df5702fc68684cef3e09f9cb6ed85578ddb08dc43593b1694c977f396fa/valkey_glide-2.4.1-cp313-cp313-macosx_10_7_x86_64.whl", hash = "sha256:c18976553ba663c03f7cc18c7e6075f4cbd2236c18b051e3d55bb213c6c44cb4", size = 7472972, upload-time = "2026-05-28T21:41:16.063Z" }, + { url = "https://files.pythonhosted.org/packages/54/a3/6a74c6f996fa9e411e66b6f0e645fead2e0a341f1371e4cf3212efa54412/valkey_glide-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:43006e19cd63d66051263fa34a8ad47ba7d08a199585689b3f12f56ed6c9a005", size = 6943012, upload-time = "2026-05-28T21:41:17.492Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e7/d10ec41dca703f8c5dcbcba2b905e660c1cf56be53c4d5e368d7aa23d220/valkey_glide-2.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b652a2a62aad87738e8f0e0aa5bf660ba91449c9fdb88550ccbc42e5fec08fe7", size = 7237842, upload-time = "2026-05-28T21:41:18.995Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/8916a9ed9e871686db444c86e601773245852ba1ad451ce1bb06f7aed91d/valkey_glide-2.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd27d26947fd9f1b6e9eaf0abce4bccfde779c1e618b310c4d725424b609793", size = 7678919, upload-time = "2026-05-28T21:41:20.502Z" }, + { url = "https://files.pythonhosted.org/packages/05/35/6d39ec3cbd24d85ad8e1051e29e6509c0999f760aff5af7851c1a1981471/valkey_glide-2.4.1-cp314-cp314-macosx_10_7_x86_64.whl", hash = "sha256:91fb7ff97acdabc8f641255b548a48627bb731e65037b1126745bf8a0022e87d", size = 7471906, upload-time = "2026-05-28T21:41:22.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/fc/3c28f794b7d35e13101598669c1d249c0a9f0408c545c87212e364c6ee4e/valkey_glide-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d49a2537c2de44b0fc57691b1ae6c3d6f481e6f7f7eb879c0d28921d0aaec67d", size = 6943495, upload-time = "2026-05-28T21:41:23.783Z" }, + { url = "https://files.pythonhosted.org/packages/2e/15/fb884631f5df78dc538c56bca9391165e40906b9b63ca65633d1be5bf980/valkey_glide-2.4.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cded9f14e448da5a96f61c066395f2c7e2846f2afe74cacc8634da0ae0c3425f", size = 7257720, upload-time = "2026-05-28T21:41:25.361Z" }, + { url = "https://files.pythonhosted.org/packages/73/79/0b881017194386d21812b929a81dd8afd51d6b8d92280895b45913854785/valkey_glide-2.4.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f249ab5bd0d69befe35897cf51a8fc9e01e9c8c9fe03087a68e6fe6d3e31d0d", size = 7682318, upload-time = "2026-05-28T21:41:26.996Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4d/f2b4e508692fcd21e76c7cbdc4f988bec7f4675e60f4f35ef482a826f6ae/valkey_glide-2.4.1-pp311-pypy311_pp73-macosx_10_7_x86_64.whl", hash = "sha256:775df9c7421a187c41caf003e4af5f073ed7e4b8abe50f8b9bec712cb03e12bf", size = 7479155, upload-time = "2026-05-28T21:41:42.399Z" }, + { url = "https://files.pythonhosted.org/packages/52/d8/8a3495f5582dccb4c8e7faf6a73baf3dbc4580701923f06d8abf210ff22d/valkey_glide-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0d87f21c77004240189cc3c5aab156966487afd81ffdee04225a52c7bd7132e4", size = 6938571, upload-time = "2026-05-28T21:41:44.078Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5a/a70077f76c2f18e94ec4309857b248beb7a8c7a3a50e30242abde2c3827d/valkey_glide-2.4.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44376ef5fe7a25287095b073d8abde510a50b1ead0143662394b3da9717863ef", size = 7260021, upload-time = "2026-05-28T21:41:45.837Z" }, + { url = "https://files.pythonhosted.org/packages/aa/12/72d31522e06fcc9b391118c1f69a09002224e78114b1db0d01b96008dc59/valkey_glide-2.4.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a59cc0a21d7a8b1b3caeb299f23817429b5fe6579bd4cb016382e6b7a10de984", size = 7693093, upload-time = "2026-05-28T21:41:47.617Z" }, +] + [[package]] name = "virtualenv" version = "20.36.1" @@ -5931,6 +6147,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, ] +[[package]] +name = "wcmatch" +version = "10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, +] + [[package]] name = "websocket-client" version = "1.9.0" @@ -6072,10 +6300,10 @@ wheels = [ [[package]] name = "xmltodict" version = "1.0.4" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61", size = 26124, upload-time = "2026-02-22T02:21:22.074Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61", size = 26124, upload-time = "2026-02-22T02:21:22.074Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580, upload-time = "2026-02-22T02:21:21.039Z" }, + { url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580, upload-time = "2026-02-22T02:21:21.039Z" }, ] [[package]] diff --git a/web/.gitignore b/web/.gitignore index d50a18139..1326feb7c 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -12,6 +12,8 @@ # testing /coverage +/playwright-report +/test-results # next.js /dist/ diff --git a/web/.prettierrc.mjs b/web/.prettierrc.mjs old mode 100644 new mode 100755 diff --git a/web/README.md b/web/README.md index ae1de3cf5..b8ed72c66 100644 --- a/web/README.md +++ b/web/README.md @@ -1,3 +1,13 @@ # Debug LangBot Frontend Please refer to the [Development Guide](https://link.langbot.app/en/docs/dev-config) for more information. + +## Tests + +Run the frontend smoke tests without a backend process: + +```bash +pnpm test:e2e +``` + +The Playwright suite starts Vite and mocks the LangBot backend and Space APIs. diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs old mode 100644 new mode 100755 diff --git a/web/fix_router.sh b/web/fix_router.sh old mode 100644 new mode 100755 diff --git a/web/package-lock.json b/web/package-lock.json index 93c3d096e..044c59c31 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -35,16 +35,16 @@ "@tailwindcss/postcss": "^4.1.5", "@tanstack/react-table": "^8.21.3", "@vitejs/plugin-react": "^6.0.1", - "axios": "^1.13.5", + "axios": "^1.16.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "highlight.js": "^11.11.1", "i18next": "^25.1.2", "i18next-browser-languagedetector": "^8.1.0", "input-otp": "^1.4.2", - "lodash": "^4.17.23", + "lodash": "^4.18.0", "lucide-react": "^0.507.0", - "postcss": "^8.5.3", + "postcss": "^8.5.10", "qrcode": "^1.5.4", "react": "19.2.1", "react-dom": "19.2.1", @@ -52,7 +52,7 @@ "react-i18next": "^15.5.1", "react-markdown": "^10.1.0", "react-photo-view": "^1.2.7", - "react-router-dom": "^7.14.0", + "react-router-dom": "^7.15.0", "react-syntax-highlighter": "^16.1.0", "recharts": "2.15.4", "rehype-autolink-headings": "^7.1.0", @@ -65,11 +65,11 @@ "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.5", "uuidjs": "^5.1.0", - "vite": "^8.0.3", + "vite": "^8.0.16", "zod": "^3.24.4" }, "devDependencies": { - "@eslint/eslintrc": "^3", + "@playwright/test": "^1.61.0", "@types/debug": "^4.1.12", "@types/estree": "^1.0.8", "@types/estree-jsx": "^1.0.5", @@ -86,6 +86,8 @@ "eslint": "^9", "eslint-config-prettier": "^10.1.2", "eslint-plugin-prettier": "^5.2.6", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", "lint-staged": "^15.5.1", "prettier": "^3.5.3", "tw-animate-css": "^1.2.9", @@ -168,20 +170,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -189,9 +191,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "license": "MIT", "optional": true, "dependencies": { @@ -490,21 +492,27 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -523,6 +531,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -1923,9 +1947,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -1939,9 +1963,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -1955,9 +1979,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -1971,9 +1995,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -1987,9 +2011,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -2003,9 +2027,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -2019,9 +2043,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -2035,9 +2059,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -2051,9 +2075,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -2067,9 +2091,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -2083,9 +2107,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -2099,9 +2123,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -2115,43 +2139,27 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -2165,9 +2173,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -2403,6 +2411,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -2865,24 +2927,37 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2987,6 +3062,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3067,21 +3154,186 @@ "node": ">=10" } }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/bail": { @@ -3125,6 +3377,25 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3138,6 +3409,23 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3572,6 +3860,60 @@ "node": ">=12" } }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3624,6 +3966,42 @@ "dev": true, "license": "MIT" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3676,6 +4054,19 @@ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -3738,6 +4129,75 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3756,10 +4216,38 @@ "node": ">= 0.4" } }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -3783,6 +4271,37 @@ "node": ">= 0.4" } }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3903,6 +4422,62 @@ } } }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -4152,16 +4727,16 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -4178,17 +4753,33 @@ } } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4225,6 +4816,47 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -4306,6 +4938,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/github-slugger": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", @@ -4338,6 +4988,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4356,6 +5023,19 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4366,6 +5046,35 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4394,9 +5103,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4653,6 +5362,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -4756,6 +5478,21 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -4789,6 +5526,141 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-decimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", @@ -4809,6 +5681,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", @@ -4822,6 +5710,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4845,6 +5753,32 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4855,6 +5789,23 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -4867,6 +5818,54 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", @@ -4880,6 +5879,110 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4887,6 +5990,24 @@ "dev": true, "license": "ISC" }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -4903,10 +6024,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4936,6 +6067,22 @@ "dev": true, "license": "MIT" }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5298,9 +6445,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -6372,9 +7519,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", "dev": true, "license": "ISC", "dependencies": { @@ -6391,9 +7538,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -6415,6 +7562,35 @@ "dev": true, "license": "MIT" }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/npm-run-path": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", @@ -6453,6 +7629,104 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/onetime": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", @@ -6487,6 +7761,24 @@ "node": ">= 0.8.0" } }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6597,6 +7889,13 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6604,9 +7903,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -6629,6 +7928,53 @@ "node": ">=0.10" } }, + "node_modules/playwright": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -6638,10 +7984,20 @@ "node": ">=10.13.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -6658,7 +8014,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -6736,10 +8092,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -6922,9 +8281,9 @@ } }, "node_modules/react-router": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", - "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", + "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -6944,12 +8303,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz", - "integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", + "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", "license": "MIT", "dependencies": { - "react-router": "7.14.0" + "react-router": "7.17.0" }, "engines": { "node": ">=20.0.0" @@ -7085,6 +8444,29 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/refractor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", @@ -7101,6 +8483,27 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/rehype-autolink-headings": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz", @@ -7278,6 +8681,30 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "license": "ISC" }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -7329,13 +8756,13 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -7344,29 +8771,84 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -7398,6 +8880,55 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7421,6 +8952,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7493,6 +9100,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -7528,6 +9149,105 @@ "dev": true, "license": "MIT" }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -7615,6 +9335,19 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/synckit": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", @@ -7667,13 +9400,13 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -7700,9 +9433,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -7786,6 +9519,84 @@ "node": ">= 0.8.0" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -7824,6 +9635,25 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -8068,16 +9898,16 @@ } }, "node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", - "tinyglobby": "^0.2.15" + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -8093,8 +9923,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -8440,12 +10270,101 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "license": "ISC" }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/web/package.json b/web/package.json index 23a3c0ece..3c3903d4b 100644 --- a/web/package.json +++ b/web/package.json @@ -6,6 +6,7 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", + "test:e2e": "playwright test", "lint": "eslint .", "format": "prettier --write ." }, @@ -16,7 +17,15 @@ ] }, "overrides": { - "@radix-ui/react-focus-scope": "1.1.7" + "js-yaml": ">=4.2.0 <5", + "form-data": ">=4.0.6", + "@radix-ui/react-focus-scope": "1.1.7", + "flatted": ">=3.4.2", + "follow-redirects": ">=1.16.0", + "minimatch@>=3.0.0 <3.1.3": "3.1.3", + "minimatch@>=9.0.0 <9.0.7": "9.0.7", + "picomatch@>=2.0.0 <2.3.2": "2.3.2", + "picomatch@>=4.0.0 <4.0.4": "4.0.4" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -46,7 +55,7 @@ "@tailwindcss/postcss": "^4.1.5", "@tanstack/react-table": "^8.21.3", "@vitejs/plugin-react": "^6.0.1", - "axios": "^1.15.0", + "axios": "^1.16.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "highlight.js": "^11.11.1", @@ -55,7 +64,7 @@ "input-otp": "^1.4.2", "lodash": "^4.18.0", "lucide-react": "^0.507.0", - "postcss": "^8.5.3", + "postcss": "^8.5.10", "qrcode": "^1.5.4", "react": "19.2.1", "react-dom": "19.2.1", @@ -63,7 +72,7 @@ "react-i18next": "^15.5.1", "react-markdown": "^10.1.0", "react-photo-view": "^1.2.7", - "react-router-dom": "^7.14.0", + "react-router-dom": "^7.15.0", "react-syntax-highlighter": "^16.1.0", "recharts": "2.15.4", "rehype-autolink-headings": "^7.1.0", @@ -76,10 +85,11 @@ "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.5", "uuidjs": "^5.1.0", - "vite": "^8.0.5", + "vite": "^8.0.16", "zod": "^3.24.4" }, "devDependencies": { + "@playwright/test": "^1.61.0", "@types/debug": "^4.1.12", "@types/estree": "^1.0.8", "@types/estree-jsx": "^1.0.5", @@ -107,7 +117,14 @@ "packageManager": "pnpm@8.9.2+sha512.b9d35fe91b2a5854dadc43034a3e7b2e675fa4b56e20e8e09ef078fa553c18f8aed44051e7b36e8b8dd435f97eb0c44c4ff3b44fc7c6fa7d21e1fac17bbe661e", "pnpm": { "overrides": { - "minimatch": "3.1.3" + "js-yaml": ">=4.2.0 <5", + "form-data": ">=4.0.6", + "minimatch@>=3.0.0 <3.1.3": "3.1.3", + "minimatch@>=9.0.0 <9.0.7": "9.0.7", + "picomatch@>=2.0.0 <2.3.2": "2.3.2", + "picomatch@>=4.0.0 <4.0.4": "4.0.4", + "flatted": ">=3.4.2", + "follow-redirects": ">=1.16.0" } } } diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 000000000..e15c6ef9e --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['github'], ['list']] : 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'pnpm exec vite --host 127.0.0.1 --port 4173', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 259d537be..ab0954fe5 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -5,7 +5,14 @@ settings: excludeLinksFromLockfile: false overrides: - minimatch: 3.1.3 + js-yaml: '>=4.2.0 <5' + form-data: '>=4.0.6' + minimatch@>=3.0.0 <3.1.3: 3.1.3 + minimatch@>=9.0.0 <9.0.7: 9.0.7 + picomatch@>=2.0.0 <2.3.2: 2.3.2 + picomatch@>=4.0.0 <4.0.4: 4.0.4 + flatted: '>=3.4.2' + follow-redirects: '>=1.16.0' dependencies: '@dnd-kit/core': @@ -88,10 +95,10 @@ dependencies: version: 8.21.3(react-dom@19.2.1)(react@19.2.1) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.8) + version: 6.0.1(vite@8.0.16) axios: - specifier: ^1.15.0 - version: 1.15.0 + specifier: ^1.16.0 + version: 1.17.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -117,8 +124,8 @@ dependencies: specifier: ^0.507.0 version: 0.507.0(react@19.2.1) postcss: - specifier: ^8.5.3 - version: 8.5.6 + specifier: ^8.5.10 + version: 8.5.15 qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -141,8 +148,8 @@ dependencies: specifier: ^1.2.7 version: 1.2.7(react-dom@19.2.1)(react@19.2.1) react-router-dom: - specifier: ^7.14.0 - version: 7.14.0(react-dom@19.2.1)(react@19.2.1) + specifier: ^7.15.0 + version: 7.17.0(react-dom@19.2.1)(react@19.2.1) react-syntax-highlighter: specifier: ^16.1.0 version: 16.1.0(react@19.2.1) @@ -180,13 +187,16 @@ dependencies: specifier: ^5.1.0 version: 5.1.0 vite: - specifier: ^8.0.5 - version: 8.0.8(@types/node@20.19.30) + specifier: ^8.0.16 + version: 8.0.16(@types/node@20.19.30) zod: specifier: ^3.24.4 version: 3.25.76 devDependencies: + '@playwright/test': + specifier: ^1.61.0 + version: 1.61.0 '@types/debug': specifier: ^4.1.12 version: 4.1.12 @@ -312,8 +322,8 @@ packages: tslib: 2.8.1 dev: false - /@emnapi/core@1.9.2: - resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + /@emnapi/core@1.10.0: + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} requiresBuild: true dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -321,8 +331,8 @@ packages: dev: false optional: true - /@emnapi/runtime@1.9.2: - resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + /@emnapi/runtime@1.10.0: + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} requiresBuild: true dependencies: tslib: 2.8.1 @@ -387,7 +397,7 @@ packages: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.2.0 minimatch: 3.1.3 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -502,21 +512,21 @@ packages: '@jridgewell/sourcemap-codec': 1.5.5 dev: false - /@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): + /@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} requiresBuild: true peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.9.2 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.10.1 dev: false optional: true - /@oxc-project/types@0.124.0: - resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} + /@oxc-project/types@0.133.0: + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} dev: false /@pkgr/core@0.2.9: @@ -524,6 +534,14 @@ packages: engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} dev: true + /@playwright/test@1.61.0: + resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==} + engines: {node: '>=18'} + hasBin: true + dependencies: + playwright: 1.61.0 + dev: true + /@radix-ui/number@1.1.1: resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} dev: false @@ -1562,8 +1580,8 @@ packages: resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} dev: false - /@rolldown/binding-android-arm64@1.0.0-rc.15: - resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} + /@rolldown/binding-android-arm64@1.0.3: + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] @@ -1571,8 +1589,8 @@ packages: dev: false optional: true - /@rolldown/binding-darwin-arm64@1.0.0-rc.15: - resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==} + /@rolldown/binding-darwin-arm64@1.0.3: + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] @@ -1580,8 +1598,8 @@ packages: dev: false optional: true - /@rolldown/binding-darwin-x64@1.0.0-rc.15: - resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==} + /@rolldown/binding-darwin-x64@1.0.3: + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] @@ -1589,8 +1607,8 @@ packages: dev: false optional: true - /@rolldown/binding-freebsd-x64@1.0.0-rc.15: - resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==} + /@rolldown/binding-freebsd-x64@1.0.3: + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] @@ -1598,8 +1616,8 @@ packages: dev: false optional: true - /@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15: - resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==} + /@rolldown/binding-linux-arm-gnueabihf@1.0.3: + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -1607,8 +1625,8 @@ packages: dev: false optional: true - /@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15: - resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==} + /@rolldown/binding-linux-arm64-gnu@1.0.3: + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -1616,8 +1634,8 @@ packages: dev: false optional: true - /@rolldown/binding-linux-arm64-musl@1.0.0-rc.15: - resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} + /@rolldown/binding-linux-arm64-musl@1.0.3: + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -1625,8 +1643,8 @@ packages: dev: false optional: true - /@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15: - resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} + /@rolldown/binding-linux-ppc64-gnu@1.0.3: + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] @@ -1634,8 +1652,8 @@ packages: dev: false optional: true - /@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15: - resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} + /@rolldown/binding-linux-s390x-gnu@1.0.3: + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] @@ -1643,8 +1661,8 @@ packages: dev: false optional: true - /@rolldown/binding-linux-x64-gnu@1.0.0-rc.15: - resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} + /@rolldown/binding-linux-x64-gnu@1.0.3: + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -1652,8 +1670,8 @@ packages: dev: false optional: true - /@rolldown/binding-linux-x64-musl@1.0.0-rc.15: - resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} + /@rolldown/binding-linux-x64-musl@1.0.3: + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -1661,8 +1679,8 @@ packages: dev: false optional: true - /@rolldown/binding-openharmony-arm64@1.0.0-rc.15: - resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} + /@rolldown/binding-openharmony-arm64@1.0.3: + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] @@ -1670,20 +1688,20 @@ packages: dev: false optional: true - /@rolldown/binding-wasm32-wasi@1.0.0-rc.15: - resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==} - engines: {node: '>=14.0.0'} + /@rolldown/binding-wasm32-wasi@1.0.3: + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] requiresBuild: true dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.9.2 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) dev: false optional: true - /@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15: - resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==} + /@rolldown/binding-win32-arm64-msvc@1.0.3: + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] @@ -1691,8 +1709,8 @@ packages: dev: false optional: true - /@rolldown/binding-win32-x64-msvc@1.0.0-rc.15: - resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==} + /@rolldown/binding-win32-x64-msvc@1.0.3: + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1700,14 +1718,14 @@ packages: dev: false optional: true - /@rolldown/pluginutils@1.0.0-rc.15: - resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} - dev: false - /@rolldown/pluginutils@1.0.0-rc.7: resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} dev: false + /@rolldown/pluginutils@1.0.1: + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + dev: false + /@standard-schema/utils@0.3.0: resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} dev: false @@ -1866,7 +1884,7 @@ packages: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 - postcss: 8.5.6 + postcss: 8.5.15 tailwindcss: 4.1.18 dev: false @@ -2117,7 +2135,7 @@ packages: '@typescript-eslint/types': 8.54.0 '@typescript-eslint/visitor-keys': 8.54.0 debug: 4.4.3 - minimatch: 3.1.3 + minimatch: 9.0.7 semver: 7.7.3 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -2155,7 +2173,7 @@ packages: resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} dev: false - /@vitejs/plugin-react@6.0.1(vite@8.0.8): + /@vitejs/plugin-react@6.0.1(vite@8.0.16): resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: @@ -2169,7 +2187,7 @@ packages: optional: true dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.8(@types/node@20.19.30) + vite: 8.0.16(@types/node@20.19.30) dev: false /acorn-jsx@5.3.2(acorn@8.15.0): @@ -2186,6 +2204,15 @@ packages: hasBin: true dev: true + /agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + dev: false + /ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} dependencies: @@ -2328,14 +2355,16 @@ packages: possible-typed-array-names: 1.1.0 dev: true - /axios@1.15.0: - resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + /axios@1.17.0: + resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==} dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.5 + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color dev: false /bail@2.0.2: @@ -2346,6 +2375,11 @@ packages: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: true + /balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + dev: true + /brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} dependencies: @@ -2353,6 +2387,13 @@ packages: concat-map: 0.0.1 dev: true + /brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + dependencies: + balanced-match: 4.0.4 + dev: true + /braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -3094,7 +3135,7 @@ packages: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: - picomatch: ^3 || ^4 + picomatch: 4.0.4 peerDependenciesMeta: picomatch: optional: true @@ -3135,16 +3176,16 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} dependencies: - flatted: 3.3.3 + flatted: 3.4.2 keyv: 4.5.4 dev: true - /flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + /flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} dev: true - /follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + /follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -3160,14 +3201,14 @@ packages: is-callable: 1.2.7 dev: true - /form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + /form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.4 mime-types: 2.1.35 dev: false @@ -3176,6 +3217,14 @@ packages: engines: {node: '>=0.4.x'} dev: false + /fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + /fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3330,6 +3379,13 @@ packages: dependencies: function-bind: 1.1.2 + /hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + dependencies: + function-bind: 1.1.2 + dev: false + /hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} dependencies: @@ -3479,6 +3535,16 @@ packages: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} dev: false + /https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + dev: false + /human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -3810,8 +3876,8 @@ packages: /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - /js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + /js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true dependencies: argparse: 2.0.1 @@ -4648,7 +4714,7 @@ packages: engines: {node: '>=8.6'} dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 dev: true /mime-db@1.52.0: @@ -4679,11 +4745,18 @@ packages: brace-expansion: 1.1.12 dev: true + /minimatch@9.0.7: + resolution: {integrity: sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 5.0.6 + dev: true + /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - /nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + /nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true dev: false @@ -4880,8 +4953,8 @@ packages: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} dev: false - /picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + /picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} dev: true @@ -4895,6 +4968,22 @@ packages: hasBin: true dev: true + /playwright-core@1.61.0: + resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} + engines: {node: '>=18'} + hasBin: true + dev: true + + /playwright@1.61.0: + resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} + engines: {node: '>=18'} + hasBin: true + dependencies: + playwright-core: 1.61.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + /pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -4905,20 +4994,11 @@ packages: engines: {node: '>= 0.4'} dev: true - /postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + /postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - dev: false - - /postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} - engines: {node: ^10 || ^12 || >=14} - dependencies: - nanoid: 3.3.11 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 dev: false @@ -5094,8 +5174,8 @@ packages: use-sidecar: 1.1.3(@types/react@19.2.10)(react@19.2.1) dev: false - /react-router-dom@7.14.0(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==} + /react-router-dom@7.17.0(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -5103,11 +5183,11 @@ packages: dependencies: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - react-router: 7.14.0(react-dom@19.2.1)(react@19.2.1) + react-router: 7.17.0(react-dom@19.2.1)(react@19.2.1) dev: false - /react-router@7.14.0(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==} + /react-router@7.17.0(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -5372,29 +5452,29 @@ packages: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} dev: true - /rolldown@1.0.0-rc.15: - resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==} + /rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true dependencies: - '@oxc-project/types': 0.124.0 - '@rolldown/pluginutils': 1.0.0-rc.15 + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.15 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.15 - '@rolldown/binding-darwin-x64': 1.0.0-rc.15 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.15 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.15 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.15 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.15 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 dev: false /safe-array-concat@1.1.3: @@ -5745,6 +5825,15 @@ packages: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + dev: true + + /tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + dev: false /to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} @@ -6007,13 +6096,13 @@ packages: d3-timer: 3.0.1 dev: false - /vite@8.0.8(@types/node@20.19.30): - resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} + /vite@8.0.16(@types/node@20.19.30): + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.0 + '@vitejs/devtools': ^0.1.18 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -6053,9 +6142,9 @@ packages: '@types/node': 20.19.30 lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.8 - rolldown: 1.0.0-rc.15 - tinyglobby: 0.2.15 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 optionalDependencies: fsevents: 2.3.3 dev: false diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs old mode 100644 new mode 100755 diff --git a/web/scripts/check-i18n.mjs b/web/scripts/check-i18n.mjs old mode 100644 new mode 100755 diff --git a/web/src/app/RootLayout.tsx b/web/src/app/RootLayout.tsx new file mode 100644 index 000000000..444de4b4a --- /dev/null +++ b/web/src/app/RootLayout.tsx @@ -0,0 +1,9 @@ +import { Outlet } from 'react-router-dom'; +import { useDocumentTitle } from '@/hooks/useDocumentTitle'; + +// Top-level route layout: drives the dynamic document title from the active +// route and renders the matched child route via . +export default function RootLayout() { + useDocumentTitle(); + return ; +} diff --git a/web/src/app/global.css b/web/src/app/global.css index 75f5dc7fb..4b8e93cd5 100644 --- a/web/src/app/global.css +++ b/web/src/app/global.css @@ -74,6 +74,15 @@ } } +/* Hide scrollbar while keeping scroll behaviour (horizontal tag/filter rows). */ +.scrollbar-hide { + -ms-overflow-style: none; /* IE / Edge */ + scrollbar-width: none; /* Firefox */ +} +.scrollbar-hide::-webkit-scrollbar { + display: none; /* Chrome / Safari / WebKit */ +} + @custom-variant dark (&:is(.dark *)); @theme inline { diff --git a/web/src/app/home/add-extension/page.tsx b/web/src/app/home/add-extension/page.tsx new file mode 100644 index 000000000..358f37da3 --- /dev/null +++ b/web/src/app/home/add-extension/page.tsx @@ -0,0 +1,1415 @@ +import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + Download, + PlusIcon, + ChevronLeft, + ChevronRight, + Server, + Github, + BookOpen, + FileArchive, + Loader2, + CircleHelp, + Package, +} from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { httpClient, systemInfo } from '@/app/infra/http/HttpClient'; +import { getCloudServiceClientSync } from '@/app/infra/http'; +import { extractI18nObject } from '@/i18n/I18nProvider'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { PluginV4 } from '@/app/infra/entities/plugin'; +import type { Skill } from '@/app/infra/entities/api'; +import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; +import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task'; +import MCPForm from '@/app/home/mcp/components/mcp-form/MCPForm'; +import type { + MCPFormDraft, + MCPFormHandle, +} from '@/app/home/mcp/components/mcp-form/MCPForm'; +import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel'; +import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel'; + +type PopoverView = 'menu' | 'mcp' | 'github'; + +enum GithubInstallStatus { + WAIT_INPUT = 'wait_input', + SELECT_RELEASE = 'select_release', + SELECT_ASSET = 'select_asset', + ASK_CONFIRM = 'ask_confirm', + INSTALLING = 'installing', + SKILL_PREVIEW = 'skill_preview', + SKILL_INSTALLING = 'skill_installing', + ERROR = 'error', +} + +interface GithubRelease { + id: number; + tag_name: string; + name: string; + published_at: string; + prerelease: boolean; + draft: boolean; + source_type?: 'release' | 'tag' | 'branch'; + archive_url?: string; +} + +interface GithubAsset { + id: number; + name: string; + size: number; + download_url: string; + content_type: string; +} + +interface GithubSkillMdInfo { + owner: string; + repo: string; + ref: string; + path: string; +} + +function isGithubSkillMdUrl(rawUrl: string): boolean { + try { + const url = new URL(rawUrl.trim()); + return url.pathname.toLowerCase().endsWith('/skill.md'); + } catch { + return rawUrl.trim().toLowerCase().split('?', 1)[0].endsWith('skill.md'); + } +} + +function parseGithubSkillMdUrl(rawUrl: string): GithubSkillMdInfo { + const url = new URL(rawUrl.trim()); + const parts = url.pathname.split('/').filter(Boolean); + + if (url.hostname === 'github.com') { + if (parts.length < 5 || parts[2] !== 'blob') { + throw new Error('Invalid GitHub SKILL.md URL'); + } + return { + owner: parts[0], + repo: parts[1], + ref: parts[3], + path: parts.slice(4).join('/'), + }; + } + + if (url.hostname === 'raw.githubusercontent.com') { + if (parts.length < 4) { + throw new Error('Invalid GitHub SKILL.md URL'); + } + return { + owner: parts[0], + repo: parts[1], + ref: parts[2], + path: parts.slice(3).join('/'), + }; + } + + throw new Error('Invalid GitHub SKILL.md URL'); +} + +enum PluginInstallStatus { + ASK_CONFIRM = 'ask_confirm', + INSTALLING = 'installing', + ERROR = 'error', +} + +export default function AddExtensionPage() { + const { t } = useTranslation(); + + if (!systemInfo?.enable_marketplace) { + return ( +
+

{t('plugins.marketplace')}

+
+ ); + } + + return ; +} + +function AddExtensionContent() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData(); + + // Localized label for an extension type, used in the install dialog. + const extensionTypeLabel = (type: string) => + type === 'mcp' + ? t('market.typeMCP') + : type === 'skill' + ? t('market.typeSkill') + : t('market.typePlugin'); + const { + addTask, + setSelectedTaskId, + registerOnTaskComplete, + unregisterOnTaskComplete, + clearCompletedTasks, + } = usePluginInstallTasks(); + const [modalOpen, setModalOpen] = useState(false); + const [installInfo, setInstallInfo] = useState>({}); + const [installExtensionType, setInstallExtensionType] = useState< + 'plugin' | 'mcp' | 'skill' + >('plugin'); + const [pluginInstallStatus, setPluginInstallStatus] = + useState(PluginInstallStatus.ASK_CONFIRM); + const [installError, setInstallError] = useState(null); + const [installIconFailed, setInstallIconFailed] = useState(false); + + // Marketplace icon URL for the extension being installed, by type. + const installIconURL = (() => { + const cloud = getCloudServiceClientSync(); + const a = installInfo.plugin_author || ''; + const n = installInfo.plugin_name || ''; + return cloud.resolveMarketplaceIconURL( + installExtensionType, + a, + n, + installInfo.plugin_icon, + ); + })(); + + // When the resolved icon URL changes (e.g. the real external icon arrives + // after an async fetch), clear any prior load failure so the retries + // instead of staying on the placeholder. + useEffect(() => { + setInstallIconFailed(false); + }, [installIconURL]); + + const [popoverOpen, setPopoverOpen] = useState(false); + const [popoverView, setPopoverView] = useState('menu'); + const [isDragOver, setIsDragOver] = useState(false); + const [skillUploadPreviewOpen, setSkillUploadPreviewOpen] = useState(false); + const [skillUploadPreviewFile, setSkillUploadPreviewFile] = + useState(null); + const [pluginUploadPreviewOpen, setPluginUploadPreviewOpen] = useState(false); + const [pluginUploadPreviewFile, setPluginUploadPreviewFile] = + useState(null); + const fileInputRef = useRef(null); + const mcpFormRef = useRef(null); + const [mcpTesting, setMcpTesting] = useState(false); + const [mcpDraft, setMcpDraft] = useState(); + + // GitHub install state + const [githubURL, setGithubURL] = useState(''); + const [githubReleases, setGithubReleases] = useState([]); + const [selectedRelease, setSelectedRelease] = useState( + null, + ); + const [githubAssets, setGithubAssets] = useState([]); + const [selectedAsset, setSelectedAsset] = useState(null); + const [githubOwner, setGithubOwner] = useState(''); + const [githubRepo, setGithubRepo] = useState(''); + const [fetchingReleases, setFetchingReleases] = useState(false); + const [fetchingAssets, setFetchingAssets] = useState(false); + const [fetchingSkillPreview, setFetchingSkillPreview] = useState(false); + const [githubSkillInfo, setGithubSkillInfo] = + useState(null); + const [githubSkillPreview, setGithubSkillPreview] = useState( + null, + ); + const [githubInstallStatus, setGithubInstallStatus] = + useState(GithubInstallStatus.WAIT_INPUT); + const [githubInstallError, setGithubInstallError] = useState( + null, + ); + + useEffect(() => { + // Clear any stale completed tasks on mount + clearCompletedTasks(); + }, [clearCompletedTasks]); + + useEffect(() => { + if (searchParams.get('manual') !== '1') return; + + setPopoverView('menu'); + setPopoverOpen(true); + setSearchParams( + (current) => { + const next = new URLSearchParams(current); + next.delete('manual'); + return next; + }, + { replace: true }, + ); + }, [searchParams, setSearchParams]); + + // One-click install deep link from LangBot Space: + // /home/add-extension?install=1&extension_type=mcp&author=X&name=Y&version=Z + // Opens the install confirm dialog directly, then strips the params. + useEffect(() => { + if (searchParams.get('install') !== '1') return; + const author = searchParams.get('author'); + const name = searchParams.get('name'); + if (!author || !name) return; + const rawType = + searchParams.get('extension_type') || + searchParams.get('type') || + 'plugin'; + const extType = ( + ['plugin', 'mcp', 'skill'].includes(rawType) ? rawType : 'plugin' + ) as 'plugin' | 'mcp' | 'skill'; + const version = searchParams.get('version') || ''; + + setInstallInfo({ + plugin_author: author, + plugin_name: name, + plugin_version: version, + plugin_label: name, + }); + setInstallExtensionType(extType); + setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM); + setInstallError(null); + setInstallIconFailed(false); + setModalOpen(true); + + // The icon is not carried in the URL params, so fetch it from the + // marketplace record. Without this the confirm dialog falls back to the + // /resources/icon endpoint, which 404s for extensions whose icon is an + // external URL (simpleicons / iconify), showing a placeholder. + const cloud = getCloudServiceClientSync(); + cloud + .fetchMarketplaceIcon(extType, author, name) + .then((icon) => { + if (!icon) return; + setInstallInfo((prev) => + prev.plugin_author === author && prev.plugin_name === name + ? { ...prev, plugin_icon: icon } + : prev, + ); + }) + .catch(() => {}); + + setSearchParams( + (current) => { + const next = new URLSearchParams(current); + [ + 'install', + 'extension_type', + 'type', + 'author', + 'name', + 'version', + ].forEach((k) => next.delete(k)); + return next; + }, + { replace: true }, + ); + }, [searchParams, setSearchParams]); + + useEffect(() => { + const onComplete = (_taskId: number, success: boolean) => { + if (success) { + toast.success(t('addExtension.installSuccess')); + // Refresh every sidebar extension list so the newly-installed + // plugin / MCP / skill shows up immediately, regardless of type. + refreshPlugins(); + refreshMCPServers(); + refreshSkills(); + } + }; + registerOnTaskComplete(onComplete); + return () => { + unregisterOnTaskComplete(onComplete); + }; + }, [ + registerOnTaskComplete, + unregisterOnTaskComplete, + refreshPlugins, + refreshMCPServers, + refreshSkills, + t, + ]); + + const handleInstallPlugin = useCallback(async (plugin: PluginV4) => { + setInstallInfo({ + plugin_author: plugin.author, + plugin_name: plugin.name, + plugin_version: plugin.latest_version, + plugin_label: extractI18nObject(plugin.label) || plugin.name, + plugin_description: extractI18nObject(plugin.description) || '', + plugin_icon: plugin.icon || '', + }); + setInstallExtensionType(plugin.type || 'plugin'); + setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM); + setInstallError(null); + setInstallIconFailed(false); + setModalOpen(true); + }, []); + + function handleModalConfirm() { + setPluginInstallStatus(PluginInstallStatus.INSTALLING); + const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`; + httpClient + .installPluginFromMarketplace( + installInfo.plugin_author, + installInfo.plugin_name, + installInfo.plugin_version, + ) + .then((resp: { task_id: number }) => { + const taskId = resp.task_id; + const taskKey = `marketplace-${taskId}`; + addTask({ + taskId, + pluginName: pluginDisplayName, + source: 'marketplace', + extensionType: installExtensionType, + }); + setSelectedTaskId(taskKey); + setModalOpen(false); + }) + .catch((err: { msg?: string }) => { + setInstallError(err.msg || null); + setPluginInstallStatus(PluginInstallStatus.ERROR); + }); + } + + const validateFileType = (file: File): boolean => { + const allowedExtensions = ['.lbpkg', '.zip']; + const fileName = file.name.toLowerCase(); + return allowedExtensions.some((ext) => fileName.endsWith(ext)); + }; + + const getExtensionTypeFromFile = (file: File): 'plugin' | 'skill' => { + const fileName = file.name.toLowerCase(); + if (fileName.endsWith('.lbpkg')) return 'plugin'; + if (fileName.endsWith('.zip')) return 'skill'; + return 'plugin'; + }; + + const uploadFile = useCallback( + async (file: File) => { + if (!validateFileType(file)) { + toast.error(t('addExtension.unsupportedFileType')); + return; + } + + const extType = getExtensionTypeFromFile(file); + + setPopoverOpen(false); + // Clear any selected task to avoid showing stale dialogs + setSelectedTaskId(null); + + if (extType === 'plugin') { + setPluginUploadPreviewFile(file); + setPluginUploadPreviewOpen(true); + } else { + setSkillUploadPreviewFile(file); + setSkillUploadPreviewOpen(true); + } + }, + [t, setSelectedTaskId], + ); + + const handleFileSelect = useCallback(() => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }, []); + + const handleFileChange = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + uploadFile(file); + } + event.target.value = ''; + }, + [uploadFile], + ); + + const handleDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault(); + setIsDragOver(true); + }, []); + + const handleDragLeave = useCallback((event: React.DragEvent) => { + event.preventDefault(); + setIsDragOver(false); + }, []); + + const handleDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + setIsDragOver(false); + const files = Array.from(event.dataTransfer.files); + if (files.length > 0) { + uploadFile(files[0]); + } + }, + [uploadFile], + ); + + function handleMCPCreated(_serverName: string) { + setMcpDraft(undefined); + refreshMCPServers(); + setPopoverView('menu'); + setPopoverOpen(false); + } + + async function checkExtensionsLimit(): Promise { + const maxExtensions = systemInfo.limitation?.max_extensions ?? -1; + if (maxExtensions < 0) return true; + try { + const [pluginsResp, mcpResp, skillsResp] = await Promise.all([ + httpClient.getPlugins(), + httpClient.getMCPServers(), + httpClient.getSkills(), + ]); + const total = + (pluginsResp.plugins?.length ?? 0) + + (mcpResp.servers?.length ?? 0) + + (skillsResp.skills?.length ?? 0); + if (total >= maxExtensions) { + toast.error( + t('limitation.maxExtensionsReached', { max: maxExtensions }), + ); + return false; + } + } catch { + // If we can't check, let backend handle it + } + return true; + } + + function resetGithubState() { + setGithubURL(''); + setGithubReleases([]); + setSelectedRelease(null); + setGithubAssets([]); + setSelectedAsset(null); + setGithubOwner(''); + setGithubRepo(''); + setFetchingReleases(false); + setFetchingAssets(false); + setFetchingSkillPreview(false); + setGithubSkillInfo(null); + setGithubSkillPreview(null); + setGithubInstallStatus(GithubInstallStatus.WAIT_INPUT); + setGithubInstallError(null); + } + + async function handleGithubAddressSubmit() { + if (isGithubSkillMdUrl(githubURL)) { + await previewGithubSkillMd(); + return; + } + await fetchGithubReleases(); + } + + async function fetchGithubReleases() { + if (!githubURL.trim()) { + toast.error(t('plugins.enterRepoUrl')); + return; + } + + setFetchingReleases(true); + setGithubInstallError(null); + setGithubSkillInfo(null); + setGithubSkillPreview(null); + + try { + const result = await httpClient.getGithubReleases(githubURL); + setGithubReleases(result.releases); + setGithubOwner(result.owner); + setGithubRepo(result.repo); + + if (result.releases.length === 0) { + toast.warning(t('plugins.noReleasesFound')); + } else { + setGithubInstallStatus(GithubInstallStatus.SELECT_RELEASE); + } + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + setGithubInstallError(errorMessage || t('plugins.fetchReleasesError')); + setGithubInstallStatus(GithubInstallStatus.ERROR); + } finally { + setFetchingReleases(false); + } + } + + async function previewGithubSkillMd() { + if (!githubURL.trim()) { + toast.error(t('addExtension.githubUrlRequired')); + return; + } + + setFetchingSkillPreview(true); + setGithubInstallError(null); + setGithubReleases([]); + setGithubAssets([]); + setSelectedRelease(null); + setSelectedAsset(null); + + try { + const skillInfo = parseGithubSkillMdUrl(githubURL); + const result = await httpClient.previewSkillInstallFromGithub( + githubURL.trim(), + skillInfo.owner, + skillInfo.repo, + skillInfo.ref, + ); + const preview = result.skills?.[0]; + if (!preview) { + throw new Error(t('addExtension.noSkillPreviewFound')); + } + setGithubOwner(skillInfo.owner); + setGithubRepo(skillInfo.repo); + setGithubSkillInfo(skillInfo); + setGithubSkillPreview(preview); + setGithubInstallStatus(GithubInstallStatus.SKILL_PREVIEW); + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + setGithubInstallError(errorMessage || t('skills.previewLoadError')); + setGithubInstallStatus(GithubInstallStatus.ERROR); + } finally { + setFetchingSkillPreview(false); + } + } + + async function handleReleaseSelect(release: GithubRelease) { + setSelectedRelease(release); + setFetchingAssets(true); + setGithubInstallError(null); + + try { + const result = await httpClient.getGithubReleaseAssets( + githubOwner, + githubRepo, + release.id, + release.tag_name, + release.source_type, + release.archive_url, + ); + setGithubAssets(result.assets); + + if (result.assets.length === 0) { + toast.warning(t('plugins.noAssetsFound')); + } else { + setGithubInstallStatus(GithubInstallStatus.SELECT_ASSET); + } + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + setGithubInstallError(errorMessage || t('plugins.fetchAssetsError')); + setGithubInstallStatus(GithubInstallStatus.ERROR); + } finally { + setFetchingAssets(false); + } + } + + function handleAssetSelect(asset: GithubAsset) { + setSelectedAsset(asset); + setGithubInstallStatus(GithubInstallStatus.ASK_CONFIRM); + } + + async function handleGithubConfirm() { + if (!selectedAsset || !selectedRelease) return; + if (!(await checkExtensionsLimit())) return; + + setGithubInstallStatus(GithubInstallStatus.INSTALLING); + const pluginDisplayName = `${githubOwner}/${githubRepo}`; + httpClient + .installPluginFromGithub( + selectedAsset.download_url, + githubOwner, + githubRepo, + selectedRelease.tag_name, + ) + .then((resp) => { + const taskId = resp.task_id; + const taskKey = `github-${taskId}`; + addTask({ + taskId, + pluginName: pluginDisplayName, + source: 'github', + extensionType: 'plugin', + fileSize: selectedAsset.size, + }); + setSelectedTaskId(taskKey); + resetGithubState(); + setPopoverOpen(false); + }) + .catch((err) => { + setGithubInstallError(err.msg); + setGithubInstallStatus(GithubInstallStatus.ERROR); + }); + } + + async function handleGithubSkillConfirm() { + if (!githubSkillInfo) return; + if (!(await checkExtensionsLimit())) return; + + setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING); + try { + await httpClient.installSkillFromGithub( + githubURL.trim(), + githubSkillInfo.owner, + githubSkillInfo.repo, + githubSkillInfo.ref, + ); + toast.success(t('skills.installSuccess')); + refreshPlugins(); + refreshSkills(); + resetGithubState(); + setPopoverOpen(false); + } catch (err: unknown) { + const errorMessage = + err instanceof Error + ? err.message + : typeof err === 'object' && err && 'msg' in err + ? String((err as { msg?: string }).msg || '') + : String(err); + setGithubInstallError(errorMessage); + setGithubInstallStatus(GithubInstallStatus.ERROR); + } + } + + function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; + } + + function getPopoverWidth(): string { + switch (popoverView) { + case 'mcp': + return 'w-[calc(100vw-2rem)] sm:w-[560px]'; + case 'github': + return 'w-[calc(100vw-2rem)] sm:w-[560px]'; + default: + return 'w-[calc(100vw-2rem)] sm:w-[380px]'; + } + } + + const extensionActions = ( + <> + + + { + setPopoverOpen(open); + }} + > + + + + + {/* ===== Menu View ===== */} + {popoverView === 'menu' && ( +
+ {/* File upload area */} +
+ +

+ {t('addExtension.uploadExtension')} +

+

+ {t('addExtension.uploadHint')} +

+
+ +

+ {t('addExtension.orContinueWith')} +

+ +
+ + + + + +
+
+ )} + + {/* ===== MCP Form View ===== */} + {popoverView === 'mcp' && ( +
+
+ +

+ {t('mcp.createServer')} +

+
+ +
+ {}} + onNewServerCreated={handleMCPCreated} + onDraftChange={setMcpDraft} + onTestingChange={setMcpTesting} + /> +
+ +
+ + +
+
+ )} + + {/* ===== GitHub Install View ===== */} + {popoverView === 'github' && ( +
+
+ +

+ {t('addExtension.installFromGithub')} +

+
+ +
+ {githubInstallStatus === GithubInstallStatus.WAIT_INPUT && ( +
+
+ {t('addExtension.githubUrlHelp')} + + + + + + {t('addExtension.githubUrlTooltip')} + + +
+ setGithubURL(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleGithubAddressSubmit(); + }} + /> + +
+ )} + + {githubInstallStatus === GithubInstallStatus.SELECT_RELEASE && ( +
+
+

+ {t('plugins.selectRelease')} +

+ +
+
+ {githubReleases.map((release) => ( +
handleReleaseSelect(release)} + > +
+
+ {release.name || release.tag_name} +
+
+ {release.tag_name} •{' '} + {new Date( + release.published_at, + ).toLocaleDateString()} +
+
+ {release.prerelease && ( + + Pre + + )} +
+ ))} +
+ {fetchingAssets && ( +

+ + {t('plugins.loading')} +

+ )} +
+ )} + + {githubInstallStatus === GithubInstallStatus.SELECT_ASSET && ( +
+
+

+ {t('plugins.selectAsset')} +

+ +
+ {selectedRelease && ( +
+ + {selectedRelease.name || selectedRelease.tag_name} + +
+ )} +
+ {githubAssets.map((asset) => ( +
handleAssetSelect(asset)} + > + {asset.name} + + {formatFileSize(asset.size)} + +
+ ))} +
+
+ )} + + {githubInstallStatus === GithubInstallStatus.ASK_CONFIRM && ( +
+
+

+ {t('plugins.confirmInstall')} +

+ +
+ {selectedRelease && selectedAsset && ( +
+
+ Repository: + + {githubOwner}/{githubRepo} + +
+
+ Release: + {selectedRelease.tag_name} +
+
+ File: + {selectedAsset.name} +
+
+ )} + +
+ )} + + {githubInstallStatus === GithubInstallStatus.SKILL_PREVIEW && ( +
+
+

+ {t('addExtension.previewSkill')} +

+ +
+ + {githubSkillPreview && ( +
+
+ + + +
+
+ {githubSkillPreview.display_name || + githubSkillPreview.name} +
+
+ {githubSkillPreview.name} +
+
+
+ {githubSkillPreview.description && ( +

+ {githubSkillPreview.description} +

+ )} +
+
+ + Repository:{' '} + + {githubSkillInfo?.owner}/{githubSkillInfo?.repo} +
+
+ + File:{' '} + + + {githubSkillInfo?.path} + +
+ {githubSkillPreview.package_root && ( +
+ + Directory:{' '} + + + {githubSkillPreview.package_root} + +
+ )} +
+
+ )} + + +
+ )} + + {githubInstallStatus === GithubInstallStatus.INSTALLING && ( +
+ + {t('plugins.installing')} +
+ )} + + {githubInstallStatus === + GithubInstallStatus.SKILL_INSTALLING && ( +
+ + {t('skills.installing')} +
+ )} + + {githubInstallStatus === GithubInstallStatus.ERROR && ( +
+

+ {t('plugins.installFailed')} +

+ {githubInstallError && ( +

+ {githubInstallError} +

+ )} + +
+ )} +
+
+ )} +
+
+ + ); + + return ( + <> +
+
+ +
+
+ + { + setModalOpen(open); + if (!open) { + setInstallError(null); + } + }} + > + + + + + + {t('addExtension.installTitle', { + type: extensionTypeLabel(installExtensionType), + })} + + + + + {pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( +
+

+ {t('addExtension.installConfirm', { + type: extensionTypeLabel(installExtensionType), + name: installInfo.plugin_label || installInfo.plugin_name, + })} +

+
+ {installIconFailed ? ( +
+ +
+ ) : ( + {installInfo.plugin_name} setInstallIconFailed(true)} + /> + )} +
+
+ {installInfo.plugin_label || installInfo.plugin_name} +
+
+ {installInfo.plugin_author}/{installInfo.plugin_name} + {installInfo.plugin_version + ? ` · v${installInfo.plugin_version}` + : ''} +
+ {installInfo.plugin_description && ( +
+ {installInfo.plugin_description} +
+ )} +
+
+
+ )} + + {pluginInstallStatus === PluginInstallStatus.INSTALLING && ( +
+

{t('plugins.installing')}

+
+ )} + + {pluginInstallStatus === PluginInstallStatus.ERROR && ( +
+

{t('plugins.installFailed')}

+

{installError}

+
+ )} + + + {pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( + <> + + + + )} + {pluginInstallStatus === PluginInstallStatus.ERROR && ( + + )} + +
+
+ + {/* Plugin Upload Preview Dialog */} + { + setPluginUploadPreviewOpen(open); + if (!open) { + setPluginUploadPreviewFile(null); + } + }} + > + + + + + {t('plugins.localPreview.title')} + + + {pluginUploadPreviewFile && ( + { + setPluginUploadPreviewOpen(false); + setPluginUploadPreviewFile(null); + }} + onInstallStarted={() => { + setPluginUploadPreviewOpen(false); + setPluginUploadPreviewFile(null); + }} + /> + )} + + + + {/* Skill Upload Preview Dialog */} + { + setSkillUploadPreviewOpen(open); + if (!open) { + setSkillUploadPreviewFile(null); + } + }} + > + + + + + {t('skills.uploadZip')} + + + {skillUploadPreviewFile && ( + { + setSkillUploadPreviewOpen(false); + setSkillUploadPreviewFile(null); + }} + onImported={(skillNames) => { + setSkillUploadPreviewOpen(false); + setSkillUploadPreviewFile(null); + void refreshSkills(); + const firstSkillName = skillNames[0]; + if (firstSkillName) { + navigate( + `/home/skills?id=${encodeURIComponent(firstSkillName)}`, + ); + } + }} + /> + )} + + + + ); +} diff --git a/web/src/app/home/bots/BotDetailContent.tsx b/web/src/app/home/bots/BotDetailContent.tsx index 8440e47ac..6c0a9ccc1 100644 --- a/web/src/app/home/bots/BotDetailContent.tsx +++ b/web/src/app/home/bots/BotDetailContent.tsx @@ -191,45 +191,47 @@ export default function BotDetailContent({ id }: { id: string }) { onValueChange={setActiveTab} className="flex flex-1 flex-col min-h-0" > - - - - {t('bots.configuration')} - - - - {t('bots.logs')} - - - - {t('bots.sessionMonitor.title')} - {activeTab === 'sessions' && ( - - )} - - +
+ + + + {t('bots.configuration')} + + + + {t('bots.logs')} + + + + {t('bots.sessionMonitor.title')} + + + {activeTab === 'sessions' && ( + + )} +
{/* Tab: Configuration */} void; + admins: BotAdmin[]; + onAdminsChange: () => void; +} + +export default function BotAdminsDialog({ + botId, + open, + onOpenChange, + admins, + onAdminsChange, +}: BotAdminsDialogProps) { + const { t } = useTranslation(); + const [newType, setNewType] = useState('person'); + const [newId, setNewId] = useState(''); + const [adding, setAdding] = useState(false); + + async function handleAdd() { + if (!newId.trim()) return; + setAdding(true); + try { + await httpClient.addBotAdmin(botId, newType, newId.trim()); + toast.success(t('bots.admins.addSuccess')); + setNewId(''); + onAdminsChange(); + } catch (e: unknown) { + const err = e as { msg?: string; message?: string }; + toast.error(t('bots.admins.addError') + (err?.msg ?? err?.message ?? '')); + } finally { + setAdding(false); + } + } + + async function handleDelete(id: number) { + try { + await httpClient.deleteBotAdmin(botId, id); + toast.success(t('bots.admins.deleteSuccess')); + onAdminsChange(); + } catch (e: unknown) { + const err = e as { msg?: string; message?: string }; + toast.error( + t('bots.admins.deleteError') + (err?.msg ?? err?.message ?? ''), + ); + } + } + + return ( + + + + + + {t('bots.admins.title')} + + {t('bots.admins.description')} + + +
+ {/* Add row */} +
+ + setNewId(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAdd()} + /> + +
+ + {/* List */} + {admins.length === 0 ? ( +
+ {t('bots.admins.noAdmins')} +
+ ) : ( + +
+ + + + + + + + + {admins.map((admin) => ( + + + + + + ))} + +
+ {t('bots.admins.launcherType')} + + {t('bots.admins.launcherId')} + +
+ + {admin.launcher_type === 'person' + ? t('bots.admins.typePerson') + : t('bots.admins.typeGroup')} + + + {admin.launcher_id} + + +
+
+
+ )} +
+
+
+ ); +} + +// Shared hook so the session monitor and the dialog stay in sync. +export function useBotAdmins(botId: string) { + const [admins, setAdmins] = useState([]); + + const reload = useCallback(async () => { + try { + const res = await httpClient.getBotAdmins(botId); + setAdmins(res.admins ?? []); + } catch (error) { + console.error('Failed to load bot admins:', error); + } + }, [botId]); + + useEffect(() => { + reload(); + }, [reload]); + + return { admins, reload }; +} diff --git a/web/src/app/home/bots/components/bot-card/BotCard.tsx b/web/src/app/home/bots/components/bot-card/BotCard.tsx index 3551ed667..c5a1cba8d 100644 --- a/web/src/app/home/bots/components/bot-card/BotCard.tsx +++ b/web/src/app/home/bots/components/bot-card/BotCard.tsx @@ -4,6 +4,7 @@ import { httpClient } from '@/app/infra/http/HttpClient'; import { Switch } from '@/components/ui/switch'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; +import { MessageSquare, Workflow } from 'lucide-react'; export default function BotCard({ botCardVO, @@ -42,28 +43,14 @@ export default function BotCard({
- - - + {botCardVO.adapterLabel}
- - - + {botCardVO.usePipelineName} diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index 1f48e18e9..1dd03a640 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -13,6 +13,7 @@ import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; import { UUID } from 'uuidjs'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import { httpClient } from '@/app/infra/http/HttpClient'; +import { systemInfo } from '@/app/infra/http'; import { Bot } from '@/app/infra/entities/api'; import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; import { ExternalLink } from 'lucide-react'; @@ -625,6 +626,7 @@ export default function BotForm({ extra_webhook_url: extraWebhookUrl, bot_uuid: initBotId || '', adapter_config: form.getValues('adapter_config') || {}, + outbound_ips: systemInfo.outbound_ips, }} /> )} diff --git a/web/src/app/home/bots/components/bot-form/RoutingRulesEditor.tsx b/web/src/app/home/bots/components/bot-form/RoutingRulesEditor.tsx index 42b866261..f8c7efbdf 100644 --- a/web/src/app/home/bots/components/bot-form/RoutingRulesEditor.tsx +++ b/web/src/app/home/bots/components/bot-form/RoutingRulesEditor.tsx @@ -48,7 +48,6 @@ interface PipelineOption { } interface RoutingRulesEditorProps { - // eslint-disable-next-line @typescript-eslint/no-explicit-any form: UseFormReturn; pipelineNameList: PipelineOption[]; } diff --git a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx index e38da3893..3a181134a 100644 --- a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx +++ b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useRef, useCallback, + useMemo, forwardRef, useImperativeHandle, } from 'react'; @@ -15,10 +16,20 @@ import { Bot, Copy, Check, + ChevronDown, + ChevronRight, Workflow, ThumbsUp, ThumbsDown, + ShieldCheck, + ShieldOff, + Wrench, } from 'lucide-react'; +import { toast } from 'sonner'; +import BotAdminsDialog, { + useBotAdmins, +} from '@/app/home/bots/components/bot-admins/BotAdminsDialog'; +import type { BotAdmin } from '@/app/home/bots/components/bot-admins/BotAdminsDialog'; import { copyToClipboard } from '@/app/utils/clipboard'; import { MessageChainComponent, @@ -69,6 +80,35 @@ interface SessionFeedback { stream_id?: string | null; } +interface SessionToolCall { + id: string; + timestamp: string; + tool_name: string; + tool_source: string; + duration: number; + status: string; + message_id?: string | null; + arguments?: string | null; + result?: string | null; + error_message?: string | null; +} + +type SessionTimelineItem = + | { + id: string; + type: 'message'; + timestamp: number; + order: number; + message: SessionMessage; + } + | { + id: string; + type: 'tool'; + timestamp: number; + order: number; + toolCall: SessionToolCall; + }; + export interface BotSessionMonitorHandle { refreshSessions: () => Promise; } @@ -93,16 +133,65 @@ const BotSessionMonitor = forwardRef< const [feedbackMap, setFeedbackMap] = useState< Record >({}); + const [toolCalls, setToolCalls] = useState([]); + const [expandedToolCallIds, setExpandedToolCallIds] = useState< + Record + >({}); const messagesContainerRef = useRef(null); + const { admins, reload: reloadAdmins } = useBotAdmins(botId); + const [adminsDialogOpen, setAdminsDialogOpen] = useState(false); + const [togglingAdmin, setTogglingAdmin] = useState(null); const parseSessionType = (sessionId: string): string | null => { - const idx = sessionId.indexOf('_'); - if (idx === -1) return null; - const type = sessionId.slice(0, idx); - if (type === 'person' || type === 'group') return type; + const lower = sessionId.toLowerCase(); + if (lower.includes('person')) return 'person'; + if (lower.includes('group')) return 'group'; return null; }; + const isSessionAdmin = (session: SessionInfo): boolean => { + const type = parseSessionType(session.session_id); + const lid = + session.user_id ?? + session.session_id.replace( + /^.*?[._](?:PERSON|GROUP|person|group)[._]/i, + '', + ); + return admins.some( + (a: BotAdmin) => a.launcher_type === type && a.launcher_id === lid, + ); + }; + + const toggleAdmin = async (session: SessionInfo) => { + const type = parseSessionType(session.session_id); + if (!type) return; + const lid = + session.user_id ?? + session.session_id.replace( + /^.*?[._](?:PERSON|GROUP|person|group)[._]/i, + '', + ); + const key = session.session_id; + setTogglingAdmin(key); + try { + const existing = admins.find( + (a: BotAdmin) => a.launcher_type === type && a.launcher_id === lid, + ); + if (existing) { + await httpClient.deleteBotAdmin(botId, existing.id); + toast.success(t('bots.admins.deleteSuccess')); + } else { + await httpClient.addBotAdmin(botId, type, lid); + toast.success(t('bots.admins.addSuccess')); + } + await reloadAdmins(); + } catch { + toast.error(t('bots.admins.addError')); + } finally { + setTogglingAdmin(null); + } + }; + const abbreviateId = (id: string): string => { if (id.length <= 10) return id; return `${id.slice(0, 4)}..${id.slice(-4)}`; @@ -137,6 +226,7 @@ const BotSessionMonitor = forwardRef< const loadMessages = useCallback( async (sessionId: string) => { setLoadingMessages(true); + setExpandedToolCallIds({}); try { const messagesRes = await httpClient.getSessionMessages(sessionId); const sorted = (messagesRes.messages ?? []).sort( @@ -145,6 +235,18 @@ const BotSessionMonitor = forwardRef< ); setMessages(sorted); + try { + const analysisRes = await httpClient.get<{ + tool_calls?: SessionToolCall[]; + }>( + `/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`, + ); + setToolCalls(analysisRes?.tool_calls ?? []); + } catch (analysisError) { + console.error('Failed to load session tool calls:', analysisError); + setToolCalls([]); + } + // Collect user message IDs for feedback matching const userMsgIds = new Set( sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id), @@ -188,11 +290,14 @@ const BotSessionMonitor = forwardRef< loadMessages(selectedSessionId); } else { setMessages([]); + setToolCalls([]); + setExpandedToolCallIds({}); + setFeedbackMap({}); } }, [selectedSessionId, loadMessages]); useEffect(() => { - if (messages.length === 0) return; + if (messages.length === 0 && toolCalls.length === 0) return; // Wait for DOM to render the new messages before scrolling requestAnimationFrame(() => { const container = messagesContainerRef.current; @@ -204,7 +309,7 @@ const BotSessionMonitor = forwardRef< scrollTarget.scrollTop = scrollTarget.scrollHeight; } }); - }, [messages]); + }, [messages, toolCalls]); const parseMessageChain = (content: string): MessageChainComponent[] => { try { @@ -379,262 +484,510 @@ const BotSessionMonitor = forwardRef< return `${diffDays}d`; }; + const formatDuration = (durationMs: number): string => { + if (!durationMs) return '0ms'; + if (durationMs < 1000) return `${durationMs}ms`; + return `${(durationMs / 1000).toFixed(2)}s`; + }; + + const truncateToolDetail = (value?: string | null): string => { + if (!value) return ''; + return value.length > 600 ? `${value.slice(0, 600)}...` : value; + }; + + const toggleToolCallDetails = (toolCallId: string) => { + setExpandedToolCallIds((previous) => ({ + ...previous, + [toolCallId]: !previous[toolCallId], + })); + }; + + const feedbackByMessageId = useMemo(() => { + const map: Record = {}; + + for (let index = 0; index < messages.length; index++) { + const msg = messages[index]; + if (isUserMessage(msg)) continue; + + for (let previousIndex = index - 1; previousIndex >= 0; previousIndex--) { + const previousMessage = messages[previousIndex]; + if (isUserMessage(previousMessage)) { + const feedback = feedbackMap[previousMessage.id]; + if (feedback) { + map[msg.id] = feedback; + } + break; + } + } + } + + return map; + }, [feedbackMap, messages]); + + const timelineItems = useMemo(() => { + const messageItems: SessionTimelineItem[] = messages.map( + (message, index) => ({ + id: `message-${message.id}`, + type: 'message', + timestamp: parseTimestamp(message.timestamp).getTime(), + order: index * 2, + message, + }), + ); + const toolItems: SessionTimelineItem[] = toolCalls.map( + (toolCall, index) => ({ + id: `tool-${toolCall.id}`, + type: 'tool', + timestamp: parseTimestamp(toolCall.timestamp).getTime(), + order: index * 2 + 1, + toolCall, + }), + ); + + return [...messageItems, ...toolItems].sort( + (a, b) => a.timestamp - b.timestamp || a.order - b.order, + ); + }, [messages, toolCalls]); + const selectedSession = sessions.find( (s) => s.session_id === selectedSessionId, ); return ( -
- {/* Left Panel: Session List */} -
- {/* Session List */} - - {loadingSessions && sessions.length === 0 ? ( -
- {t('bots.sessionMonitor.loading')} -
- ) : sessions.length === 0 ? ( -
- {t('bots.sessionMonitor.noSessions')} + <> +
+ {/* Left Panel: Session List */} +
+ {/* Admin header */} +
+ +
+ {/* Session List */} + + {loadingSessions && sessions.length === 0 ? ( +
+ {t('bots.sessionMonitor.loading')} +
+ ) : sessions.length === 0 ? ( +
+ {t('bots.sessionMonitor.noSessions')} +
+ ) : ( +
+ {sessions.map((session) => { + const isSelected = selectedSessionId === session.session_id; + const sessionType = parseSessionType(session.session_id); + const sessionIsAdmin = isSessionAdmin(session); + return ( +
setSelectedSessionId(session.session_id)} + > +
+ + {session.user_name || + session.user_id || + session.session_id.slice(0, 12)} + + + {formatRelativeTime(session.last_activity)} + +
+
+ {sessionType && ( + + {sessionType} + + )} + + {session.user_id && ( + + {abbreviateId(session.user_id)} + + )} + {session.is_active && ( + + + + )} +
+
+ ); + })} +
+ )} +
+
+ + {/* Right Panel: Messages */} +
+ {!selectedSessionId ? ( +
+ {t('bots.sessionMonitor.selectSession')}
) : ( -
- {sessions.map((session) => { - const isSelected = selectedSessionId === session.session_id; - return ( - + + )} + {selectedSession?.is_active && ( + <> + · + + Active - )} -
- - ); - })} -
- )} - -
- - {/* Right Panel: Messages */} -
- {!selectedSessionId ? ( -
- {t('bots.sessionMonitor.selectSession')} -
- ) : ( - <> - {/* Chat Header */} -
-
-
- {selectedSession?.user_name || - selectedSession?.user_id || - selectedSessionId.slice(0, 20)} -
-
- {parseSessionType(selectedSessionId) && ( - {parseSessionType(selectedSessionId)} - )} - {selectedSession?.platform && ( - <> - {parseSessionType(selectedSessionId) && ·} - {selectedSession.platform} - - )} - {selectedSession?.user_id && ( - <> - · - - {selectedSession.user_id} - - - - )} - {selectedSession?.is_active && ( - <> - · - - - Active - - - )} + + )} + {selectedSession && parseSessionType(selectedSessionId) && ( + <> + · + + + )} +
-
- {/* Messages Area */} - -
- {loadingMessages ? ( -
- {t('bots.sessionMonitor.loading')} -
- ) : messages.length === 0 ? ( -
- {t('bots.sessionMonitor.noMessages')} -
- ) : ( - messages.map((msg, msgIndex) => { - const isUser = isUserMessage(msg); - const isDiscarded = - msg.status === 'discarded' || - msg.pipeline_id === PIPELINE_DISCARD; - // For bot replies, find feedback linked to the preceding user message - let msgFeedback: SessionFeedback | undefined; - if (!isUser) { - for (let i = msgIndex - 1; i >= 0; i--) { - if (isUserMessage(messages[i])) { - msgFeedback = feedbackMap[messages[i].id]; - break; - } + {/* Messages Area */} + +
+ {loadingMessages ? ( +
+ {t('bots.sessionMonitor.loading')} +
+ ) : timelineItems.length === 0 ? ( +
+ {t('bots.sessionMonitor.noMessages')} +
+ ) : ( + timelineItems.map((item) => { + if (item.type === 'tool') { + const call = item.toolCall; + const hasToolDetails = Boolean( + call.arguments || call.result || call.error_message, + ); + const expandedToolCall = Boolean( + expandedToolCallIds[call.id], + ); + const detailsId = `tool-call-details-${call.id}`; + return ( +
+
+ + + {hasToolDetails && expandedToolCall && ( +
+ {(call.arguments || call.result) && ( +
+ {call.arguments && ( +
+
+ {t( + 'monitoring.toolCalls.arguments', + { + defaultValue: '参数', + }, + )} +
+
+                                            {truncateToolDetail(call.arguments)}
+                                          
+
+ )} + {call.result && ( +
+
+ {t('monitoring.toolCalls.result', { + defaultValue: '结果', + })} +
+
+                                            {truncateToolDetail(call.result)}
+                                          
+
+ )} +
+ )} + + {call.error_message && ( +
+ {call.error_message} +
+ )} +
+ )} + +
+ + {t('monitoring.toolCalls.title', { + defaultValue: '工具调用', + })} + + + {formatTime(call.timestamp)} + + {hasToolDetails && ( + <> + · + + {expandedToolCall + ? t( + 'monitoring.toolCalls.hideDetails', + { + defaultValue: '隐藏详情', + }, + ) + : t( + 'monitoring.toolCalls.showDetails', + { + defaultValue: '查看详情', + }, + )} + + + )} +
+
+
+ ); } - } - return ( -
+ + const msg = item.message; + const isUser = isUserMessage(msg); + const isDiscarded = + msg.status === 'discarded' || + msg.pipeline_id === PIPELINE_DISCARD; + const msgFeedback = feedbackByMessageId[msg.id]; + return (
- {renderMessageContent(msg)} - {/* Role label + pipeline + timestamp */}
- - {isUser - ? t('bots.sessionMonitor.userMessage', { - defaultValue: 'User', - }) - : t('bots.sessionMonitor.botMessage', { - defaultValue: 'Assistant', + {renderMessageContent(msg)} + {/* Role label + pipeline + timestamp */} +
+ + {isUser + ? t('bots.sessionMonitor.userMessage', { + defaultValue: 'User', + }) + : t('bots.sessionMonitor.botMessage', { + defaultValue: 'Assistant', + })} + + + {formatTime(msg.timestamp)} + + {isDiscarded ? ( + + + {t('bots.sessionMonitor.discarded', { + defaultValue: 'Discarded', })} - - - {formatTime(msg.timestamp)} - - {isDiscarded ? ( - - - {t('bots.sessionMonitor.discarded', { - defaultValue: 'Discarded', - })} - - ) : msg.pipeline_name ? ( - - - {msg.pipeline_name} - - ) : null} - {msg.status === 'error' && ( - error - )} - {msg.runner_name && ( - - - {msg.runner_name} - - )} - {/* Feedback indicator — same line, pushed right */} - {!isUser && - msgFeedback && - (msgFeedback.feedback_type === 1 ? ( - - - {t('monitoring.feedback.like')} - {msgFeedback.feedback_content && ( - - {msgFeedback.feedback_content} - - )} - ) : ( - - - {t('monitoring.feedback.dislike')} - {msgFeedback.feedback_content && ( - - {msgFeedback.feedback_content} - - )} + ) : msg.pipeline_name ? ( + + + {msg.pipeline_name} - ))} + ) : null} + {msg.status === 'error' && ( + error + )} + {msg.runner_name && ( + + + {msg.runner_name} + + )} + {/* Feedback indicator — same line, pushed right */} + {!isUser && + msgFeedback && + (msgFeedback.feedback_type === 1 ? ( + + + {t('monitoring.feedback.like')} + {msgFeedback.feedback_content && ( + + {msgFeedback.feedback_content} + + )} + + ) : ( + + + {t('monitoring.feedback.dislike')} + {msgFeedback.feedback_content && ( + + {msgFeedback.feedback_content} + + )} + + ))} +
-
- ); - }) - )} -
-
- - )} + ); + }) + )} +
+
+ + )} +
-
+ + + ); }); diff --git a/web/src/app/home/components/BoxUnavailableNotice.tsx b/web/src/app/home/components/BoxUnavailableNotice.tsx new file mode 100644 index 000000000..5fe54a80a --- /dev/null +++ b/web/src/app/home/components/BoxUnavailableNotice.tsx @@ -0,0 +1,53 @@ +import { useTranslation } from 'react-i18next'; +import { Info, ShieldAlert } from 'lucide-react'; + +import { Alert, AlertDescription } from '@/components/ui/alert'; + +/** + * Banner shown when a feature depends on the Box sandbox runtime but it is + * currently disabled in config or otherwise unavailable. Pass the ``hint`` + * key returned by ``useBoxStatus`` (``'boxDisabled' | 'boxUnavailable'``). + * + * Renders nothing when there is no hint — safe to drop at the top of any + * page that may or may not need to surface the notice. + */ +export interface BoxUnavailableNoticeProps { + hint: 'boxDisabled' | 'boxUnavailable' | null; + /** Specific failure reason from the backend (``connector_error``). Shown + * on a dedicated line so the user sees WHY (e.g. ``Configured sandbox + * backend "nsjail" is unavailable``) instead of just the generic + * "unavailable" wording. Ignored when ``hint === 'boxDisabled'`` + * because the disabled-by-config message already carries the reason. */ + reason?: string | null; + className?: string; +} + +export function BoxUnavailableNotice({ + hint, + reason, + className, +}: BoxUnavailableNoticeProps) { + const { t } = useTranslation(); + if (!hint) return null; + + const variant = hint === 'boxDisabled' ? 'default' : 'destructive'; + const Icon = hint === 'boxDisabled' ? Info : ShieldAlert; + const showReason = hint === 'boxUnavailable' && reason; + + return ( + + + +
{t(`monitoring.${hint}`)}
+ {showReason && ( +
{reason}
+ )} +
+ {t('monitoring.boxRequiredHint')} +
+
+
+ ); +} + +export default BoxUnavailableNotice; diff --git a/web/src/app/home/components/account-settings-dialog/AccountSettingsDialog.tsx b/web/src/app/home/components/account-settings-dialog/AccountSettingsDialog.tsx deleted file mode 100644 index 87b438eb3..000000000 --- a/web/src/app/home/components/account-settings-dialog/AccountSettingsDialog.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import * as React from 'react'; -import { useState, useEffect } from 'react'; -import { toast } from 'sonner'; -import { useTranslation } from 'react-i18next'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { - Item, - ItemMedia, - ItemContent, - ItemTitle, - ItemDescription, - ItemActions, -} from '@/components/ui/item'; -import { httpClient } from '@/app/infra/http/HttpClient'; -import { systemInfo } from '@/app/infra/http'; -import { Loader2, ExternalLink, KeyRound } from 'lucide-react'; -import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog'; - -interface AccountSettingsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export default function AccountSettingsDialog({ - open, - onOpenChange, -}: AccountSettingsDialogProps) { - const { t } = useTranslation(); - const [accountType, setAccountType] = useState<'local' | 'space'>('local'); - const [hasPassword, setHasPassword] = useState(false); - const [userEmail, setUserEmail] = useState(''); - const [loading, setLoading] = useState(true); - const [spaceBindLoading, setSpaceBindLoading] = useState(false); - const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); - - useEffect(() => { - if (open) { - loadUserInfo(); - } - }, [open]); - - async function loadUserInfo() { - setLoading(true); - try { - const info = await httpClient.getUserInfo(); - setAccountType(info.account_type); - setHasPassword(info.has_password); - setUserEmail(info.user); - } catch { - toast.error(t('common.error')); - } finally { - setLoading(false); - } - } - - const handleBindSpace = async () => { - setSpaceBindLoading(true); - try { - const token = localStorage.getItem('token'); - if (!token) { - toast.error(t('common.error')); - setSpaceBindLoading(false); - return; - } - const currentOrigin = window.location.origin; - const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`; - // Pass token as state for security verification - const response = await httpClient.getSpaceAuthorizeUrl( - redirectUri, - token, - ); - window.location.href = response.authorize_url; - } catch { - toast.error(t('common.spaceLoginFailed')); - setSpaceBindLoading(false); - } - }; - - const handlePasswordDialogClose = (dialogOpen: boolean) => { - setPasswordDialogOpen(dialogOpen); - if (!dialogOpen) { - // Reload user info to update password status - loadUserInfo(); - } - }; - - return ( - <> - - - - {t('account.settings')} - {userEmail} - - - {loading ? ( -
- -
- ) : ( -
- {/* Password Item */} - - - - - - {t('account.passwordStatus')} - - {hasPassword - ? t('account.passwordSetDescription') - : t('account.setPasswordHint')} - - - - - - - - {/* Space Account Item */} - - - - - - - - - - {t('account.spaceStatus')} - - {accountType === 'space' - ? t('account.spaceBoundDescription') - : t('account.bindSpaceDescription')} - - - {accountType === 'local' && ( - - - - )} - -
- )} -
-
- - - - ); -} diff --git a/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx b/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx new file mode 100644 index 000000000..0795f413e --- /dev/null +++ b/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx @@ -0,0 +1,171 @@ +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { + Item, + ItemMedia, + ItemContent, + ItemTitle, + ItemDescription, + ItemActions, +} from '@/components/ui/item'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { systemInfo } from '@/app/infra/http'; +import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react'; +import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog'; +import { PanelBody } from '../settings-dialog/panel-layout'; + +interface AccountSettingsPanelProps { + // True when this panel is the active section and the dialog is open. + active: boolean; + onEmailResolved?: (email: string) => void; +} + +export default function AccountSettingsPanel({ + active, + onEmailResolved, +}: AccountSettingsPanelProps) { + const { t } = useTranslation(); + const [accountType, setAccountType] = useState<'local' | 'space'>('local'); + const [hasPassword, setHasPassword] = useState(false); + const [userEmail, setUserEmail] = useState(''); + const [loading, setLoading] = useState(true); + const [spaceBindLoading, setSpaceBindLoading] = useState(false); + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); + + useEffect(() => { + if (active) { + loadUserInfo(); + } + }, [active]); + + async function loadUserInfo() { + setLoading(true); + try { + const info = await httpClient.getUserInfo(); + setAccountType(info.account_type); + setHasPassword(info.has_password); + setUserEmail(info.user); + onEmailResolved?.(info.user); + } catch { + toast.error(t('common.error')); + } finally { + setLoading(false); + } + } + + const handleBindSpace = async () => { + setSpaceBindLoading(true); + try { + const token = localStorage.getItem('token'); + if (!token) { + toast.error(t('common.error')); + setSpaceBindLoading(false); + return; + } + const currentOrigin = window.location.origin; + const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`; + // Pass token as state for security verification + const response = await httpClient.getSpaceAuthorizeUrl( + redirectUri, + token, + ); + window.location.href = response.authorize_url; + } catch { + toast.error(t('common.spaceLoginFailed')); + setSpaceBindLoading(false); + } + }; + + const handlePasswordDialogClose = (dialogOpen: boolean) => { + setPasswordDialogOpen(dialogOpen); + if (!dialogOpen) { + // Reload user info to update password status + loadUserInfo(); + } + }; + + return ( + + {userEmail && ( +

{userEmail}

+ )} + + {loading ? ( +
+ +
+ ) : ( +
+ {/* Password Item */} + + + + + + {t('account.passwordStatus')} + + {hasPassword + ? t('account.passwordSetDescription') + : t('account.setPasswordHint')} + + + + + + + + {/* Space Account Item */} + + + + + + {t('account.spaceStatus')} + + {accountType === 'space' + ? t('account.spaceBoundDescription') + : t('account.bindSpaceDescription')} + + + {accountType === 'local' && ( + + + + )} + +
+ )} + + +
+ ); +} diff --git a/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx b/web/src/app/home/components/api-integration-dialog/ApiIntegrationPanel.tsx similarity index 58% rename from web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx rename to web/src/app/home/components/api-integration-dialog/ApiIntegrationPanel.tsx index 8ac3f496b..7aa3634a2 100644 --- a/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx +++ b/web/src/app/home/components/api-integration-dialog/ApiIntegrationPanel.tsx @@ -3,7 +3,6 @@ import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { Copy, Check, Trash2, Plus } from 'lucide-react'; -import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { Dialog, DialogContent, @@ -37,6 +36,7 @@ import { } from '@/components/ui/alert-dialog'; import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; import { backendClient } from '@/app/infra/http'; +import { PanelToolbar } from '../settings-dialog/panel-layout'; interface ApiKey { id: number; @@ -55,20 +55,15 @@ interface Webhook { created_at: string; } -interface ApiIntegrationDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; +interface ApiIntegrationPanelProps { + // True when this panel is the active section and the dialog is open. + active: boolean; } -export default function ApiIntegrationDialog({ - open, - onOpenChange, -}: ApiIntegrationDialogProps) { +export default function ApiIntegrationPanel({ + active, +}: ApiIntegrationPanelProps) { const { t } = useTranslation(); - const navigate = useNavigate(); - const location = useLocation(); - const pathname = location.pathname; - const [searchParams] = useSearchParams(); const [activeTab, setActiveTab] = useState('apikeys'); const [apiKeys, setApiKeys] = useState([]); const [webhooks, setWebhooks] = useState([]); @@ -91,33 +86,12 @@ export default function ApiIntegrationDialog({ ); const [copiedKey, setCopiedKey] = useState(null); - // Sync URL with dialog state - useEffect(() => { - if (open) { - const params = new URLSearchParams(searchParams.toString()); - params.set('action', 'showApiIntegrationSettings'); - navigate(`${pathname}?${params.toString()}`, { - preventScrollReset: true, - }); - } - }, [open]); + // MCP server endpoint, derived from the current origin. The backend serves + // the MCP server at /mcp on the same host/port as the HTTP API + web UI. + const mcpEndpoint = + typeof window !== 'undefined' ? `${window.location.origin}/mcp` : '/mcp'; - const handleOpenChange = (newOpen: boolean) => { - if (!newOpen && (deleteKeyId || deleteWebhookId)) { - return; - } - if (!newOpen) { - const params = new URLSearchParams(searchParams.toString()); - params.delete('action'); - const newUrl = params.toString() - ? `${pathname}?${params.toString()}` - : pathname; - navigate(newUrl, { preventScrollReset: true }); - } - onOpenChange(newOpen); - }; - - // 清理 body 样式,防止对话框关闭后页面无法交互 + // 清理 body 样式,防止嵌套对话框关闭后页面无法交互 useEffect(() => { if (!deleteKeyId && !deleteWebhookId) { const cleanup = () => { @@ -131,11 +105,11 @@ export default function ApiIntegrationDialog({ }, [deleteKeyId, deleteWebhookId]); useEffect(() => { - if (open) { + if (active) { loadApiKeys(); loadWebhooks(); } - }, [open]); + }, [active]); const loadApiKeys = async () => { setLoading(true); @@ -284,233 +258,275 @@ export default function ApiIntegrationDialog({ return ( <> - - - - {t('common.manageApiIntegration')} - - - - - - {t('common.apiKeys')} - - - {t('common.webhooks')} - - - - {/* API Keys Tab */} - + + + {t('common.apiKeys')} + {t('common.webhooks')} + {t('common.mcpTab')} + + {activeTab === 'apikeys' ? ( + -
- - {loading ? ( -
- {t('common.loading')} -
- ) : apiKeys.length === 0 ? ( -
- {t('common.noApiKeys')} -
- ) : ( -
- - - - - {t('common.name')} - - - {t('common.apiKeyValue')} - - - {t('common.actions')} - - - - - {apiKeys.map((item) => ( - - -
-
{item.name}
- {item.description && ( -
- {item.description} -
- )} -
-
- - - {maskApiKey(item.key)} - - - -
- - -
-
-
- ))} -
-
-
- )} - - - {/* Webhooks Tab */} - -
- {t('common.webhookHint')} -
- -
- -
- - {loading ? ( -
- {t('common.loading')} -
- ) : webhooks.length === 0 ? ( -
- {t('common.noWebhooks')} -
- ) : ( -
- - - - - {t('common.name')} - - - {t('common.webhookUrl')} - - - {t('common.webhookEnabled')} - - - {t('common.actions')} - - - - - {webhooks.map((webhook) => ( - - -
-
- {webhook.name} -
- {webhook.description && ( -
- {webhook.description} -
- )} -
-
- -
- - {webhook.url} - -
-
- - - handleToggleWebhook(webhook) - } - /> - - - - -
- ))} -
-
-
- )} -
- - - - - - - + ) : activeTab === 'webhooks' ? ( + + ) : null} + + + {/* API Keys Tab */} + +

+ {t('common.apiKeyHint')} +

+ + {loading ? ( +
+ {t('common.loading')} +
+ ) : apiKeys.length === 0 ? ( +
+ {t('common.noApiKeys')} +
+ ) : ( +
+ + + + + {t('common.name')} + + + {t('common.apiKeyValue')} + + + {t('common.actions')} + + + + + {apiKeys.map((item) => ( + + +
+
{item.name}
+ {item.description && ( +
+ {item.description} +
+ )} +
+
+ + + {maskApiKey(item.key)} + + + +
+ + +
+
+
+ ))} +
+
+
+ )} +
+ + {/* Webhooks Tab */} + +

+ {t('common.webhookHint')} +

+ + {loading ? ( +
+ {t('common.loading')} +
+ ) : webhooks.length === 0 ? ( +
+ {t('common.noWebhooks')} +
+ ) : ( +
+ + + + + {t('common.name')} + + + {t('common.webhookUrl')} + + + {t('common.webhookEnabled')} + + + {t('common.actions')} + + + + + {webhooks.map((webhook) => ( + + +
+
+ {webhook.name} +
+ {webhook.description && ( +
+ {webhook.description} +
+ )} +
+
+ +
+ + {webhook.url} + +
+
+ + handleToggleWebhook(webhook)} + /> + + + + +
+ ))} +
+
+
+ )} +
+ + {/* MCP Tab */} + +

{t('common.mcpHint')}

+ +
+ +
+ + {mcpEndpoint} + + +
+
+ +
+ +

+ {t('common.mcpAuthDesc')} +

+
+              {`X-API-Key: 
+# or
+Authorization: Bearer `}
+            
+

+ {t('common.mcpGlobalKeyNote')} +

+
+ +
+ +
+              {`{
+  "mcpServers": {
+    "langbot": {
+      "url": "${mcpEndpoint}",
+      "headers": { "X-API-Key": "" }
+    }
+  }
+}`}
+            
+
+
+ {/* Create API Key Dialog */} diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 7d3af6c23..2c91e93f1 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -1,4 +1,8 @@ -import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; +import { + DynamicFormItemType, + IDynamicFormItemSchema, + SYSTEM_FIELD_PREFIX, +} from '@/app/infra/entities/form/dynamic'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -24,11 +28,18 @@ import { Copy, Check, Globe, + Info, QrCode, Download, ExternalLink, } from 'lucide-react'; import { copyToClipboard } from '@/app/utils/clipboard'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { systemInfo } from '@/app/infra/http'; import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; @@ -46,8 +57,8 @@ function resolveShowIfValue( externalDependentValues?: Record, systemContext?: Record, ): unknown { - if (field.startsWith('__system.')) { - const key = field.slice('__system.'.length); + if (field.startsWith(SYSTEM_FIELD_PREFIX)) { + const key = field.slice(SYSTEM_FIELD_PREFIX.length); return systemContext?.[key]; } if (watchedValues[field] !== undefined) { @@ -56,6 +67,95 @@ function resolveShowIfValue( return externalDependentValues?.[field]; } +type DynamicFormValueSpec = Pick< + IDynamicFormItemSchema, + 'default' | 'name' | 'required' | 'type' +>; + +function getValueSpecs(item: IDynamicFormItemSchema): DynamicFormValueSpec[] { + if (item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR) { + return [ + item, + { + name: 'enable-all-tools', + type: DynamicFormItemType.BOOLEAN, + required: false, + default: true, + }, + ]; + } + + if (item.type === DynamicFormItemType.RESOURCES_SELECTOR) { + return [ + item, + { + name: 'mcp-resources', + type: DynamicFormItemType.UNKNOWN, + required: false, + default: [], + }, + { + name: 'mcp-resource-agent-read-enabled', + type: DynamicFormItemType.BOOLEAN, + required: false, + default: true, + }, + ]; + } + + return [item]; +} + +function getValueSchema(spec: DynamicFormValueSpec) { + if (spec.name === 'mcp-resources') { + return z.array(z.any()); + } + + switch (spec.type) { + case DynamicFormItemType.INT: + return z.number(); + case DynamicFormItemType.FLOAT: + return z.number(); + case DynamicFormItemType.BOOLEAN: + return z.boolean(); + case DynamicFormItemType.STRING: + return z.string(); + case DynamicFormItemType.STRING_ARRAY: + return z.array(z.string()); + case DynamicFormItemType.SELECT: + return z.string(); + case DynamicFormItemType.LLM_MODEL_SELECTOR: + return z.string(); + case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: + return z.string(); + case DynamicFormItemType.RERANK_MODEL_SELECTOR: + return z.string(); + case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR: + return z.string(); + case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR: + case DynamicFormItemType.RESOURCES_SELECTOR: + case DynamicFormItemType.RICH_TOOLS_SELECTOR: + case DynamicFormItemType.TOOLS_SELECTOR: + return z.array(z.string()); + case DynamicFormItemType.BOT_SELECTOR: + return z.string(); + case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: + return z.object({ + primary: z.string(), + fallbacks: z.array(z.string()), + }); + case DynamicFormItemType.PROMPT_EDITOR: + return z.array( + z.object({ + content: z.string(), + role: z.string(), + }), + ); + default: + return z.string(); + } +} + /** * Display-only component for embed code fields with copy animation. */ @@ -131,13 +231,13 @@ function WebhookUrlField({ }; return ( - - {label} -
+ + {label} +
(e.target as HTMLInputElement).select()} />
{extraUrl && ( -
+
(e.target as HTMLInputElement).select()} />
{description && ( -

{description}

+

+ {description} +

)} ); } +/** + * Display-only component for `__system.*` fields (e.g. the deployment's + * outbound IPs that the operator must add to a platform's trusted-IP list). + * Renders one read-only row per value, each with a copy button. Rendered + * outside of react-hook-form binding since the values come from + * systemContext, not user input. + */ +function SystemInfoField({ + label, + description, + values, +}: { + label: string; + description?: string; + values: string[]; +}) { + const [copiedIndex, setCopiedIndex] = useState(null); + + const handleCopy = (text: string, index: number) => { + copyToClipboard(text).catch(() => {}); + setCopiedIndex(index); + setTimeout(() => setCopiedIndex(null), 2000); + }; + + return ( + + {label} +
+ {values.map((value, index) => ( +
+ (e.target as HTMLInputElement).select()} + /> + +
+ ))} +
+ {description && ( +

+ {description} +

+ )} +
+ ); +} + +// Hover-only Radix tooltips never open on touch devices (no pointer hover), +// so the ``disabled_tooltip`` explaining why a field is locked was invisible on +// mobile. This wrapper makes the info icon also toggle the tooltip on tap while +// keeping hover behavior on desktop. +function DisabledTooltipIcon({ text }: { text: string }) { + const [open, setOpen] = useState(false); + return ( + + + + + + {text} + + + ); +} + export default function DynamicFormComponent({ itemConfigList, onSubmit, @@ -274,9 +467,16 @@ export default function DynamicFormComponent({ // model-fallback-selector) is coerced to the expected shape // so that downstream components never crash. const normalizeFieldValue = ( - item: IDynamicFormItemSchema, + item: DynamicFormValueSpec, value: unknown, ): unknown => { + if ( + item.name === 'mcp-resources' || + item.type === DynamicFormItemType.RESOURCES_SELECTOR || + item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR + ) { + return Array.isArray(value) ? value : []; + } if (item.type === 'model-fallback-selector') { if (value != null && typeof value === 'object' && !Array.isArray(value)) { const obj = value as Record; @@ -305,8 +505,9 @@ export default function DynamicFormComponent({ return value; }; - // Filter out display-only field types (e.g. webhook-url, embed-code) that should not - // participate in form state, validation, or value emission. + // Filter out display-only fields (webhook-url/embed-code/qr-code-login types + // and `__system.*`-named fields) that should not participate in form state, + // validation, or value emission. const editableItems = useMemo( () => itemConfigList.filter( @@ -314,73 +515,22 @@ export default function DynamicFormComponent({ item.type !== 'webhook-url' && item.type !== 'embed-code' && item.type !== 'qr-code-login' && - item.type !== 'download-link', + item.type !== 'download-link' && + !item.name.startsWith(SYSTEM_FIELD_PREFIX), ), [itemConfigList], ); + const editableValueSpecs = useMemo( + () => editableItems.flatMap(getValueSpecs), + [editableItems], + ); + // 根据 itemConfigList 动态生成 zod schema const formSchema = z.object( - editableItems.reduce( + editableValueSpecs.reduce( (acc, item) => { - let fieldSchema; - switch (item.type) { - case 'integer': - fieldSchema = z.number(); - break; - case 'float': - fieldSchema = z.number(); - break; - case 'boolean': - fieldSchema = z.boolean(); - break; - case 'string': - fieldSchema = z.string(); - break; - case 'array[string]': - fieldSchema = z.array(z.string()); - break; - case 'select': - fieldSchema = z.string(); - break; - case 'llm-model-selector': - fieldSchema = z.string(); - break; - case 'embedding-model-selector': - fieldSchema = z.string(); - break; - case 'rerank-model-selector': - fieldSchema = z.string(); - break; - case 'knowledge-base-selector': - fieldSchema = z.string(); - break; - case 'knowledge-base-multi-selector': - fieldSchema = z.array(z.string()); - break; - case 'bot-selector': - fieldSchema = z.string(); - break; - case 'tools-selector': - fieldSchema = z.array(z.string()); - break; - case 'model-fallback-selector': - fieldSchema = z.object({ - primary: z.string(), - fallbacks: z.array(z.string()), - }); - break; - case 'prompt-editor': - fieldSchema = z.array( - z.object({ - content: z.string(), - role: z.string(), - }), - ); - break; - default: - fieldSchema = z.string(); - } + let fieldSchema = getValueSchema(item); if ( item.required && @@ -405,7 +555,7 @@ export default function DynamicFormComponent({ const form = useForm({ resolver: zodResolver(formSchema), - defaultValues: editableItems.reduce((acc, item) => { + defaultValues: editableValueSpecs.reduce((acc, item) => { // 优先使用 initialValues,如果没有则使用默认值 const rawValue = initialValues?.[item.name] ?? item.default; return { @@ -444,7 +594,7 @@ export default function DynamicFormComponent({ if (initialValues && hasRealChange) { // 合并默认值和初始值 - const mergedValues = editableItems.reduce( + const mergedValues = editableValueSpecs.reduce( (acc, item) => { const rawValue = initialValues[item.name] ?? item.default; acc[item.name] = normalizeFieldValue(item, rawValue) as object; @@ -459,10 +609,16 @@ export default function DynamicFormComponent({ previousInitialValues.current = initialValues; } - }, [initialValues, form, editableItems]); + }, [initialValues, form, editableValueSpecs]); // Get reactive form values for conditional rendering const watchedValues = form.watch(); + const setFormValue = (name: string, value: unknown) => { + form.setValue(name as keyof FormValues, value as never, { + shouldDirty: true, + shouldValidate: true, + }); + }; // Stable ref for onSubmit to avoid re-triggering the effect when the // parent passes a new closure on every render. @@ -475,7 +631,7 @@ export default function DynamicFormComponent({ // even if the user saves without modifying any field. // form.watch(callback) only fires on subsequent changes, not on mount. const formValues = form.getValues(); - const initialFinalValues = editableItems.reduce( + const initialFinalValues = editableValueSpecs.reduce( (acc, item) => { acc[item.name] = formValues[item.name] ?? item.default; return acc; @@ -495,7 +651,7 @@ export default function DynamicFormComponent({ const subscription = form.watch(() => { const formValues = form.getValues(); - const finalValues = editableItems.reduce( + const finalValues = editableValueSpecs.reduce( (acc, item) => { acc[item.name] = formValues[item.name] ?? item.default; return acc; @@ -506,7 +662,7 @@ export default function DynamicFormComponent({ previousInitialValues.current = finalValues as Record; }); return () => subscription.unsubscribe(); - }, [form, editableItems]); + }, [form, editableValueSpecs]); // State for QR code login dialog const [qrDialogOpen, setQrDialogOpen] = useState(false); @@ -515,7 +671,7 @@ export default function DynamicFormComponent({ return (
-
+
{/* QR code login dialog */} , + externalDependentValues, + systemContext, + ); + const cond = config.disable_if; + if (cond.operator === 'eq' && dependValue === cond.value) { + isDisabledByCondition = true; + } else if (cond.operator === 'neq' && dependValue !== cond.value) { + isDisabledByCondition = true; + } else if ( + cond.operator === 'in' && + Array.isArray(cond.value) && + cond.value.includes(dependValue) + ) { + isDisabledByCondition = true; + } + } + + // All fields are disabled when editing (creation_settings are + // immutable) or when ``disable_if`` matches. + const isFieldDisabled = !!isEditing || isDisabledByCondition; + const disabledTooltip = + isDisabledByCondition && config.disabled_tooltip + ? extractI18nObject(config.disabled_tooltip) + : ''; + const renderDisabledTooltipIcon = () => + disabledTooltip ? ( + + ) : null; + + // `__system.*` fields are display-only; their value is resolved + // from systemContext (same namespace as show_if), not user input. + // Hidden entirely when the deployment doesn't provide the value. + if (config.name.startsWith(SYSTEM_FIELD_PREFIX)) { + const rawValue = + systemContext?.[config.name.slice(SYSTEM_FIELD_PREFIX.length)]; + const values = (Array.isArray(rawValue) ? rawValue : [rawValue]) + .filter((v) => v !== undefined && v !== null && v !== '') + .map(String); + if (values.length === 0) return null; + + return ( + + ); + } // Webhook URL fields are display-only; render outside of form binding if (config.type === 'webhook-url') { @@ -700,6 +917,41 @@ export default function DynamicFormComponent({ ); } + if ( + config.type === DynamicFormItemType.RICH_TOOLS_SELECTOR || + config.type === DynamicFormItemType.RESOURCES_SELECTOR + ) { + return ( + ( + + +
+ } + onFileUploaded={onFileUploaded} + setFormValue={setFormValue} + systemContext={systemContext} + /> +
+
+ +
+ )} + /> + ); + } + // Boolean fields use a special inline layout if (config.type === 'boolean') { return ( @@ -708,19 +960,20 @@ export default function DynamicFormComponent({ control={form.control} name={config.name as keyof FormValues} render={({ field }) => ( - +
-
- +
+ {extractI18nObject(config.label)} + {renderDisabledTooltipIcon()} {config.description && ( -

+

{extractI18nObject(config.description)}

)} @@ -729,7 +982,10 @@ export default function DynamicFormComponent({ } onFileUploaded={onFileUploaded} + setFormValue={setFormValue} + systemContext={systemContext} />
@@ -746,26 +1002,35 @@ export default function DynamicFormComponent({ control={form.control} name={config.name as keyof FormValues} render={({ field }) => ( - - - {extractI18nObject(config.label)}{' '} - {config.required && *} + + + + {extractI18nObject(config.label)}{' '} + {config.required && ( + * + )} + + {renderDisabledTooltipIcon()}
} onFileUploaded={onFileUploaded} + setFormValue={setFormValue} + systemContext={systemContext} />
{config.description && ( -

+

{extractI18nObject(config.description)}

)} diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index 51831bdeb..40c5619a0 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -61,17 +61,36 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog'; +import SettingsDialog, { + SettingsSection, +} from '@/app/home/components/settings-dialog/SettingsDialog'; +import ToolResourceSelectors from '@/app/home/components/dynamic-form/ToolResourceSelectors'; +import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-dialog/types'; + +function hasUsableUuid( + item: T, +): item is T & { uuid: string } { + return typeof item.uuid === 'string' && item.uuid.trim().length > 0; +} + +function hasUsableOptionName(option: { name?: string | null }): boolean { + return typeof option.name === 'string' && option.name.trim().length > 0; +} export default function DynamicFormItemComponent({ config, field, + formValues, onFileUploaded, + setFormValue, + systemContext, }: { config: IDynamicFormItemSchema; - // eslint-disable-next-line @typescript-eslint/no-explicit-any field: ControllerRenderProps; + formValues?: Record; onFileUploaded?: (fileKey: string) => void; + setFormValue?: (name: string, value: unknown) => void; + systemContext?: Record; }) { const [llmModels, setLlmModels] = useState([]); const [embeddingModels, setEmbeddingModels] = useState([]); @@ -88,22 +107,48 @@ export default function DynamicFormItemComponent({ ); const { t } = useTranslation(); const [modelsDialogOpen, setModelsDialogOpen] = useState(false); + const [settingsSection, setSettingsSection] = + useState('models'); const fetchLlmModels = () => { httpClient .getProviderLLMModels() .then((resp) => { - setLlmModels(resp.models); + setLlmModels(resp.models.filter(hasUsableUuid)); }) .catch((err) => { toast.error(t('models.getModelListError') + err.msg); }); }; + const fetchEmbeddingModels = () => { + httpClient + .getProviderEmbeddingModels() + .then((resp) => { + setEmbeddingModels(resp.models.filter(hasUsableUuid)); + }) + .catch((err) => { + toast.error(t('embedding.getModelListError') + err.msg); + }); + }; + + const fetchRerankModels = () => { + httpClient + .getProviderRerankModels() + .then((resp) => { + setRerankModels(resp.models.filter(hasUsableUuid)); + }) + .catch((err) => { + toast.error('Failed to load rerank models: ' + err.msg); + }); + }; + const handleModelsDialogChange = (open: boolean) => { setModelsDialogOpen(open); if (!open) { fetchLlmModels(); + fetchEmbeddingModels(); + fetchRerankModels(); } }; @@ -171,27 +216,13 @@ export default function DynamicFormItemComponent({ useEffect(() => { if (config.type === DynamicFormItemType.EMBEDDING_MODEL_SELECTOR) { - httpClient - .getProviderEmbeddingModels() - .then((resp) => { - setEmbeddingModels(resp.models); - }) - .catch((err) => { - toast.error(t('embedding.getModelListError') + err.msg); - }); + fetchEmbeddingModels(); } }, [config.type]); useEffect(() => { if (config.type === DynamicFormItemType.RERANK_MODEL_SELECTOR) { - httpClient - .getProviderRerankModels() - .then((resp) => { - setRerankModels(resp.models); - }) - .catch((err) => { - toast.error('Failed to load rerank models: ' + err.msg); - }); + fetchRerankModels(); } }, [config.type]); @@ -209,7 +240,7 @@ export default function DynamicFormItemComponent({ httpClient .getKnowledgeBases() .then((resp) => { - setKnowledgeBases(resp.bases); + setKnowledgeBases(resp.bases.filter(hasUsableUuid)); }) .catch((err) => { toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg); @@ -222,7 +253,7 @@ export default function DynamicFormItemComponent({ httpClient .getBots() .then((resp) => { - setBots(resp.bots); + setBots(resp.bots.filter(hasUsableUuid)); }) .catch((err) => { toast.error(t('bots.getBotListError') + err.msg); @@ -245,13 +276,23 @@ export default function DynamicFormItemComponent({ } }, [config.type]); + const handleCompositePatch = (patch: Record) => { + for (const [name, value] of Object.entries(patch)) { + if (setFormValue) { + setFormValue(name, value); + } else if (name === field.name) { + field.onChange(value); + } + } + }; + switch (config.type) { case DynamicFormItemType.INT: case DynamicFormItemType.FLOAT: return ( field.onChange(Number(e.target.value))} /> @@ -260,8 +301,8 @@ export default function DynamicFormItemComponent({ case DynamicFormItemType.STRING: if (config.options && config.options.length > 0) { return ( -
- +
+